├── .gitignore ├── README.md ├── composer.json ├── dist ├── css │ └── field.css ├── js │ └── field.js └── mix-manifest.json ├── nova-create-or-add-form-open.png ├── nova-create-or-add-form.png ├── package.json ├── resources ├── js │ ├── components │ │ ├── Create.vue │ │ ├── CreateForm.vue │ │ ├── CustomDefaultField.vue │ │ ├── CustomSearchInput.vue │ │ ├── DetailField.vue │ │ ├── FormField.vue │ │ └── IndexField.vue │ ├── field.js │ └── storage │ │ └── NovaCreateOrAddStorage.js └── sass │ └── field.scss ├── src ├── FieldServiceProvider.php ├── NovaCreateOrAdd.php └── Traits │ └── HasChildren.php └── webpack.mix.js /.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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A Nova field to replace BelongsTo field for on the go resource creation. 2 | 3 | Form Field 4 |  5 | Form Expanded 6 |  7 | 8 | ## Installation 9 | 10 | You can install the package in to a Laravel app that uses [Nova](https://nova.laravel.com) via composer: 11 | 12 | ```bash 13 | composer require shivanshrajpoot/nova-create-or-add 14 | ``` 15 | ## Usage 16 | 17 | ```php 18 | // in Resource File 19 | use Shivanshrajpoot\NovaCreateOrAdd\NovaCreateOrAdd; 20 | 21 | // ... 22 | 23 | NovaCreateOrAdd::make('Manufacturer') 24 | ->searchable() 25 | ->prepopulate() 26 | ->rules('required'), 27 | ``` 28 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shivanshrajpoot/nova-create-or-add", 3 | "description": "A Laravel Nova field.", 4 | "keywords": [ 5 | "laravel", 6 | "nova" 7 | ], 8 | "license": "MIT", 9 | "require": { 10 | "php": ">=7.1.0" 11 | }, 12 | "autoload": { 13 | "psr-4": { 14 | "Shivanshrajpoot\\NovaCreateOrAdd\\": "src/" 15 | } 16 | }, 17 | "extra": { 18 | "laravel": { 19 | "providers": [ 20 | "Shivanshrajpoot\\NovaCreateOrAdd\\FieldServiceProvider" 21 | ] 22 | } 23 | }, 24 | "config": { 25 | "sort-packages": true 26 | }, 27 | "minimum-stability": "dev", 28 | "prefer-stable": true 29 | } 30 | -------------------------------------------------------------------------------- /dist/css/field.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shivanshrajpoot/nova-create-or-add-form/c13a5aec5a2e89b1daad4377016f996be74759bf/dist/css/field.css -------------------------------------------------------------------------------- /dist/mix-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/js/field.js": "/js/field.js", 3 | "/css/field.css": "/css/field.css" 4 | } -------------------------------------------------------------------------------- /nova-create-or-add-form-open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shivanshrajpoot/nova-create-or-add-form/c13a5aec5a2e89b1daad4377016f996be74759bf/nova-create-or-add-form-open.png -------------------------------------------------------------------------------- /nova-create-or-add-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shivanshrajpoot/nova-create-or-add-form/c13a5aec5a2e89b1daad4377016f996be74759bf/nova-create-or-add-form.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "npm run development", 5 | "development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", 6 | "watch": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", 7 | "watch-poll": "npm run watch -- --watch-poll", 8 | "hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js", 9 | "prod": "npm run production", 10 | "production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js" 11 | }, 12 | "devDependencies": { 13 | "cross-env": "^5.0.0", 14 | "laravel-mix": "^1.0", 15 | "laravel-nova": "^1.0" 16 | }, 17 | "dependencies": { 18 | "popper.js": "^1.14.7", 19 | "vue": "^2.5.0", 20 | "vue-clickaway": "^2.2.2" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /resources/js/components/Create.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | × 6 | {{__('New')}} {{ singularName }} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 23 | 24 | 25 | 26 | 27 | 28 | {{__('Cancel')}} 29 | 30 | 31 | 32 | {{__('Create')}} {{ singularName }} 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 176 | -------------------------------------------------------------------------------- /resources/js/components/CreateForm.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{__('Create New')}} {{ field.singularLabel }} 6 | 7 | 8 | X 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | {{__('Create')}} {{ field.singularLabel }} 30 | 31 | 32 | 33 | 34 | 35 | 102 | -------------------------------------------------------------------------------- /resources/js/components/CustomDefaultField.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ fieldLabel }} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {{ firstError }} 15 | 16 | 17 | 18 | {{ field.helpText }} 19 | 20 | 21 | 22 | 23 | 24 | 56 | -------------------------------------------------------------------------------- /resources/js/components/CustomSearchInput.vue: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 19 | 20 | 24 | 25 | 26 | {{__('Click to choose')}} 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 45 | Create New {{ singularLabel }} 46 | 47 | 48 | 49 | 50 | 63 | 64 | 70 | 71 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 275 | -------------------------------------------------------------------------------- /resources/js/components/DetailField.vue: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /resources/js/components/FormField.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 20 | 21 | 22 | 23 | 24 | {{ selectedResource.display }} 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {{ option.display }} 33 | 34 | 35 | 36 | 45 | 50 | — 51 | 52 | 53 | 59 | {{ resource.display}} 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | {{__('With Trashed')}} 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | {{ previewLink.display }} 79 | 80 | 81 | 82 | 90 | 91 | 92 | 93 | 94 | 336 | -------------------------------------------------------------------------------- /resources/js/components/IndexField.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /resources/js/field.js: -------------------------------------------------------------------------------- 1 | Nova.booting((Vue, router) => { 2 | Vue.component('index-nova-create-or-add', require('./components/IndexField')); 3 | Vue.component('detail-nova-create-or-add', require('./components/DetailField')); 4 | Vue.component('form-nova-create-or-add', require('./components/FormField')); 5 | }) 6 | -------------------------------------------------------------------------------- /resources/js/storage/NovaCreateOrAddStorage.js: -------------------------------------------------------------------------------- 1 | export default { 2 | fetchAvailableResources(resourceName, fieldAttribute, params) { 3 | return Nova.request().get( 4 | `/nova-api/${resourceName}/associatable/${fieldAttribute}`, 5 | params 6 | ) 7 | }, 8 | 9 | determineIfSoftDeletes(resourceName) { 10 | return Nova.request().get(`/nova-api/${resourceName}/soft-deletes`) 11 | }, 12 | 13 | createNewResource(resourceName, fieldAttribute, params){ 14 | return Nova.request().post(`/nova-api/${resourceName}`) 15 | }, 16 | 17 | getFormFields(resourceName){ 18 | return Nova.request().get(`/nova-api/${resourceName}/creation-fields`) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /resources/sass/field.scss: -------------------------------------------------------------------------------- 1 | // Nova Tool CSS 2 | -------------------------------------------------------------------------------- /src/FieldServiceProvider.php: -------------------------------------------------------------------------------- 1 | resourceClass = $resource; 122 | $this->resourceName = $resource::uriKey(); 123 | $this->belongsToRelationship = $this->attribute; 124 | } 125 | 126 | /** 127 | * Determine if the field should be displayed for the given request. 128 | * 129 | * @param \Illuminate\Http\Request $request 130 | * @return bool 131 | */ 132 | public function authorize(Request $request) { 133 | return $this->isNotRedundant($request) && call_user_func( 134 | [$this->resourceClass, 'authorizedToViewAny'], $request 135 | ) && parent::authorize($request); 136 | } 137 | 138 | /** 139 | * Determine if the field is not redundant. 140 | * 141 | * Ex: Is this a "user" belongs to field in a blog post list being shown on the "user" detail page. 142 | * 143 | * @param \Illuminate\Http\Request $request 144 | * @return bool 145 | */ 146 | public function isNotRedundant(Request $request) { 147 | return (!$request->isMethod('GET') || !$request->viaResource) || 148 | ($this->resourceName !== $request->viaResource); 149 | } 150 | 151 | /** 152 | * Resolve the field's value. 153 | * 154 | * @param mixed $resource 155 | * @param string|null $attribute 156 | * @return void 157 | */ 158 | public function resolve($resource, $attribute = null) { 159 | $value = $resource->{ $this->attribute}()->withoutGlobalScopes()->first(); 160 | 161 | if ($value) { 162 | $this->belongsToId = $value->getKey(); 163 | 164 | $this->value = $this->formatDisplayValue($value); 165 | } 166 | } 167 | 168 | /** 169 | * Get the validation rules for this field. 170 | * 171 | * @param \Laravel\Nova\Http\Requests\NovaRequest $request 172 | * @return array 173 | */ 174 | public function getRules(NovaRequest $request) { 175 | $query = $this->buildAssociatableQuery( 176 | $request, $request->{ $this->attribute.'_trashed'} === 'true' 177 | ); 178 | 179 | return array_merge_recursive(parent::getRules($request), [ 180 | $this->attribute => array_filter([ 181 | $this->nullable?'nullable':'required', 182 | new Relatable($request, $query) 183 | ]), 184 | ]); 185 | } 186 | 187 | /** 188 | * Build an associatable query for the field. 189 | * 190 | * @param \Laravel\Nova\Http\Requests\NovaRequest $request 191 | * @param bool $withTrashed 192 | * @return \Illuminate\Database\Eloquent\Builder 193 | */ 194 | public function buildAssociatableQuery(NovaRequest $request, $withTrashed = false) { 195 | $model = forward_static_call( 196 | [$resourceClass = $this->resourceClass, 'newModel'] 197 | ); 198 | 199 | $query = $request->first === 'true' 200 | ?$model->newQueryWithoutScopes()->whereKey($request->current) 201 | :$resourceClass::buildIndexQuery( 202 | $request, $model->newQuery(), $request->search, 203 | [], [], TrashedStatus::fromBoolean($withTrashed) 204 | ); 205 | 206 | return $query->tap(function ($query) use ($request, $model) { 207 | forward_static_call($this->associatableQueryCallable($request, $model), $request, $query); 208 | }); 209 | } 210 | 211 | /** 212 | * Get the associatable query method name. 213 | * 214 | * @param \Laravel\Nova\Http\Requests\NovaRequest $request 215 | * @param \Illuminate\Database\Eloquent\Model $model 216 | * @return array 217 | */ 218 | protected function associatableQueryCallable(NovaRequest $request, $model) { 219 | return ($method = $this->associatableQueryMethod($request, $model)) 220 | ?[$request ->resource(), $method] 221 | :[$this ->resourceClass, 'relatableQuery']; 222 | } 223 | 224 | /** 225 | * Get the associatable query method name. 226 | * 227 | * @param \Laravel\Nova\Http\Requests\NovaRequest $request 228 | * @param \Illuminate\Database\Eloquent\Model $model 229 | * @return string 230 | */ 231 | protected function associatableQueryMethod(NovaRequest $request, $model) { 232 | $method = 'relatable'.Str::plural(class_basename($model)); 233 | 234 | if (method_exists($request->resource(), $method)) { 235 | return $method; 236 | } 237 | } 238 | 239 | /** 240 | * Format the given associatable resource. 241 | * 242 | * @param \Laravel\Nova\Http\Requests\NovaRequest $request 243 | * @param mixed $resource 244 | * @return array 245 | */ 246 | public function formatAssociatableResource(NovaRequest $request, $resource) { 247 | return array_filter([ 248 | 'avatar' => $resource->resolveAvatarUrl($request), 249 | 'display' => $this->formatDisplayValue($resource), 250 | 'value' => $resource->getKey(), 251 | ]); 252 | } 253 | 254 | /** 255 | * Specify if the relationship should be searchable. 256 | * 257 | * @param bool $value 258 | * @return $this 259 | */ 260 | public function searchable($value = true) { 261 | $this->searchable = $value; 262 | 263 | return $this; 264 | } 265 | 266 | /** 267 | * Show resource preview link. 268 | * 269 | * @param bool $value 270 | * @return $this 271 | */ 272 | public function previewLink($value = true) { 273 | $this->previewLink = $value; 274 | 275 | return $this; 276 | } 277 | 278 | /** 279 | * Specify if the relationship should be creatable. 280 | * 281 | * @param bool $value 282 | * @return $this 283 | */ 284 | public function creatable($value = true) { 285 | $this->creatable = $value; 286 | 287 | return $this; 288 | } 289 | 290 | /** 291 | * Specify a callback that should be run when the field is filled. 292 | * 293 | * @param \Closure $callback 294 | * @return $this 295 | */ 296 | public function filled($callback) { 297 | $this->filledCallback = $callback; 298 | 299 | return $this; 300 | } 301 | 302 | /** 303 | * Set the attribute name of the inverse of the relationship. 304 | * 305 | * @param string $inverse 306 | * @return $this 307 | */ 308 | public function inverse($inverse) { 309 | $this->inverse = $inverse; 310 | 311 | return $this; 312 | } 313 | 314 | /** 315 | * Indicate that the field should be nullable. 316 | * 317 | * @param bool $nullable 318 | * @return $this 319 | */ 320 | public function nullable($nullable = true, $values = null) { 321 | $this->nullable = $nullable; 322 | 323 | return $this; 324 | } 325 | 326 | /** 327 | * Set the displayable singular label of the resource. 328 | * 329 | * @return string 330 | */ 331 | public function singularLabel($singularLabel) { 332 | $this->singularLabel = $singularLabel; 333 | 334 | return $this; 335 | } 336 | 337 | public function belongsToResourcePlural() 338 | { 339 | return Str::plural(Str::lower($this->belongsToRelationship)); 340 | } 341 | 342 | /** 343 | * Get additional meta information to merge with the field payload. 344 | * 345 | * @return array 346 | */ 347 | public function meta() { 348 | return array_merge([ 349 | 'resourceName' => $this->resourceName, 350 | 'label' => forward_static_call([$this->resourceClass, 'label']), 351 | 'singularLabel' => $this->singularLabel??$this->name??forward_static_call([$this->resourceClass, 'singularLabel']), 352 | 'belongsToRelationship' => $this->belongsToRelationship, 353 | 'belongsToId' => $this->belongsToId, 354 | 'belongsToResourceName' => $this->belongsToResourcePlural(), 355 | 'nullable' => $this->nullable, 356 | 'searchable' => $this->searchable, 357 | 'previewLink' => $this->previewLink, 358 | 'creatable' => $this->creatable, 359 | 'title' => $this->resourceClass::$title 360 | ], $this->meta); 361 | } 362 | 363 | public function setChildren($value = '') { 364 | 365 | } 366 | 367 | } 368 | -------------------------------------------------------------------------------- /src/Traits/HasChildren.php: -------------------------------------------------------------------------------- 1 | attribute; 15 | } 16 | 17 | /** 18 | * Add children. 19 | * 20 | * @return self 21 | */ 22 | protected 23 | 24 | function setChildren($resource, $attribute) { 25 | return $this->withMeta([ 26 | 'children' => $this->getRelation($resource, $attribute)->get()->map(function ($model, $index) { 27 | return $this->setChild($model, $index); 28 | }), 29 | ]); 30 | } 31 | 32 | /** 33 | * Set child. 34 | * 35 | * @return self 36 | */ 37 | protected function setChild(Model $model, $index = self::INDEX) { 38 | $this->setPrefix($index+1)->setAttribute($index); 39 | 40 | $array = [ 41 | 'resourceId' => $model->id, 42 | 'resourceName' => Nova::resourceForModel($this->getRelation()->getRelated())::uriKey(), 43 | 'viaResource' => $this->viaResource, 44 | 'viaRelationship' => $this->viaRelationship, 45 | 'viaResourceId' => $this->viaResourceId, 46 | 'heading' => $this->getHeading(), 47 | 'attribute' => self::ATTRIBUTE_PREFIX.$this->attribute, 48 | 'opened' => isset($this->meta['opened']) && ($this->meta['opened'] === 'only first'?$index === 0:$this->meta['opened']), 49 | 'fields' => $this->setFieldsAttribute($this->updateFields($model))->values(), 50 | 'max' => $this->meta['max']??0, 51 | 'min' => $this->meta['min']??0, 52 | self::STATUS => null, 53 | ]; 54 | 55 | $this->removePrefix()->removeAttribute(); 56 | 57 | return $array; 58 | } 59 | 60 | /** 61 | * Get fields. 62 | * 63 | * @param Model $model 64 | * @param string|null $type 65 | * 66 | * @return FieldCollection 67 | */ 68 | private function updateFields(Model $model) { 69 | return (new $this->relatedResource($model))->updateFields(NovaRequest::create('/')); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /webpack.mix.js: -------------------------------------------------------------------------------- 1 | let mix = require('laravel-mix') 2 | 3 | mix.setPublicPath('dist') 4 | .js('resources/js/field.js', 'js') 5 | .sass('resources/sass/field.scss', 'css') 6 | --------------------------------------------------------------------------------