trevorpan's avatar

Spatie Media Library - join job_id & jobmedia_id at job creation

Trying to implement Spatie Media Library.

On the jobs/create page there's a form to create a job and a dropzone to upload media files. The issue is the dropzone uploads files immediately, but they need to be associated with the job_id which is created separately.

The other thing that's strange is, the media library comes with a media table, with a model_id. Not sure how that factors in a real project.

Sequentially, how is this achieved?

Here's the JobsController.php

    public function store(Request $request)
    {
        $this->validate(request(), [
            'jobtitle' => 'required|unique:jobs|min:3|max:255',
            'body' => 'required',
            'city' => 'required',
            'state' => 'required',
            'zipcode' => 'required|integer|min:6',
            'for' => 'required',
            'job' => 'required',
            'sub-job' => 'required',
            'deadline' => 'required|integer|between:1,10'
        ]);

        // Calculate deadline; add weeks to current time of job posting
        $week = $request->input('deadline');
        $deadline = Carbon::now()
            ->addWeeks($week)
            ->toDateTimeString();

        // Create a new job using request data
        $job = Job::create([
            'jobtitle' => request('jobtitle'), 
            'body' => request('body'),
            'city' => request('city'),
            'state' => request('state'),
            'zipcode' => request('zipcode'),
            'for' => request('for'),
            'job' => request('job'),
            'sub-job' => request('sub-job'),
            'deadline' => $deadline,
            'user_id' => auth()->id()
        ]);
        

        // event(new JobPosted($job));

        // Redirect to 
        return redirect('/jobs');
    }

Here's the JobMedia.php

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;
use Spatie\MediaLibrary\Models\Media;
use Spatie\MediaLibrary\HasMedia\HasMediaTrait;
use Spatie\MediaLibrary\HasMedia\Interfaces\HasMediaConversions;
use Spatie\MediaLibrary\HasMedia\HasMedia;
use Spatie\Image\Manipulations;
use Spatie\MediaLibrary\File;
// use Spatie\MediaLibrary\Models\Media as BaseMedia;

class JobMedia extends Model implements HasMedia
{
    use HasMediaTrait;

    protected $fillable = [
        'title',
        'description'
    ];


    public function registerMediaConversions(Media $media = null)
    {
        $this->addMediaConversion('thumbnail')
            ->width(215)
            ->height(140)
            ->extractVideoFrameAtSecond(5) // If it's a video; grab the still frame from the 5th second in the video
            ->sharpen(10);

        $this->addMediaConversion('jobfullpage')
            ->fit(Manipulations::FIT_CROP, 447, 325)
            ->apply();
    }

    public function registerMediaCollections() 
    {
        $this->addMediaCollection('thumbnail');
        $this->addMediaCollection('jobfullpage');

    }

    public function job()
    {
        return $this->belongsTo(Job::class);
    }


    public function user() //$jobmedia->user->name
    {
        return $this->belongsTo(User::class);
    }
}
0 likes
9 replies
Snapey's avatar

One option is to force the user to create the new job before adding media. So, the media area can be greyed out and you can say ''please first save job"

Alternatively, when you go to create job, actually make a new job in the create method as you are returning the form.

One issue with this is that you might end up with some abandoned jobs. You can hide these by having a status column with 'draft' or 'published' states, and then periodically clear out draft jobs with a created_at more than a day ago.

Either way, this will give you a job_id which you can then add to dropzone URL so that media is saved with the jobID being passed also.

Media has an id so that the library can manage its own rows, but each row will have a polymorphic relationship to the model its attached to (an id and model name). This way you never need to know the media ID in your job model.

trevorpan's avatar

@SNAPEY - Good morning`

Just watched Laracasts' "Polymorphic Relations" video again. I may be wrong, but this app does not have jobmedia polymorphic relations. JobMedia only belongs to a user_id & job_id. e.g. people can't "save" an image they like and post it to their profile; jobmedia are only associated with a job_id as bidders will be bidding on the job.

It made me think the JobMedia.php model is no longer needed, just attach to the Job.php model and associate via the "create" method in the JobsController.php as you noted above.

Does this change the Spatie/Media model?

Perhaps:

$table->morphs('model');

becomes:

$table->unsignedInteger('job_id');
$table->unsignedInteger('user_id');

Is this a reasonable observation?

create_media_table.php

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateMediaTable extends Migration
{
    /**
     * Run the migrations.
     */
    public function up()
    {
        Schema::create('media', function (Blueprint $table) {
            $table->increments('id');
            $table->morphs('model');
            $table->string('collection_name');
            $table->string('name');
            $table->string('file_name');
            $table->string('mime_type')->nullable();
            $table->string('disk');
            $table->unsignedInteger('size');
            $table->json('manipulations');
            $table->json('custom_properties');
            $table->json('responsive_images');
            $table->unsignedInteger('order_column')->nullable();
            $table->nullableTimestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down()
    {
        Schema::dropIfExists('media');
    }
}
Snapey's avatar

why do you even need to think about it?

When you add the media trait to any model it can have media associated to that model through a belongsTo on the media model. What model it belongs to is specified in another column,

So if you save media for a job, there will be a reference to the job model in the media table. Therewill be nothing in the job model referencing the media other than the trait.

trevorpan's avatar

@SNAPEY - I'm sorry I didn't understand the trait concept. Thank you for some clarification. I'll read up some more on it, too.

Should the JobMedia.php functions, and includes be transfered to the Job.php model? One thing I was concerned with was the protected $fillable and $guarded fields where Job has 'jobtitle', and 'body' and JobMedia files have 'title' and 'description'.

I read in the docs one model has to use guarded or fillable - not both.

Is the JobMedia.php model a good way to keep the naming fields separate? Or would you eliminate JobMedia?

If you can offer a direction I'd like to take a crack at integrating & refining tonight and see how far I can take it.

Just not clear on the overall direction to take and what's useful. I had downloaded the spatie-media-library-demo and have it working locally, but the models were for BlogPost and PhotoAlbum, and weren't directly relatable, at least at my stage in coding!

<?php

namespace App;


use App\Events\JobPosted;
use App\Mail\JobPostedMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Database\Eloquent\Model;


class Job extends Model
{
   
    protected $guarded = [];

    protected $dispatchesEvents = [
        'created' => JobPosted::class
    ];


    public function path()
    {
        return "/jobs/{$this->id}";
    }

    // protected static function boot()
    // {

    //     parent::boot();

    //     static::created(function ($job) {

    //     });

    // }

    // public function offeror()
    // {
    //     return $this->belongsTo(User::class);
    // }


      /**
     * Register the conversions that should be performed.
     *
     * @return array
     */


    public function scopeIncomplete($query)
    {
        return $query->where('bidded', 0);
    }

    public function questions()
    {
        return $this->hasMany(Question::class);
    }


    public function user() //$job->user
    {
        return $this->belongsTo(User::class);
    }

    public function askQuestion($body)
    {
        $this->questions()->create(compact('body'));
    }


    public function scopeFilter($query, $filters)
    {

        // if (isset($filters['month'])){
        if ($month = isset($filters['month'])) {
            $query->whereMonth('created_at', Carbon::parse($month)->month);
        }

        // if (isset($filters['year'])) {
        if ($year = isset($filters['year'])) {
            $query->whereYear('created_at', $year);
        }

    }

}
trevorpan's avatar

@SNAPEY - Alright, after trying to get the dropzone and form to submit I decided to try the multistep approach.

Can you see why the storeStep2() is being immediately called after finishing the first page button? It renders this error:

Call to a member function addMedia() on null

This means, it's skipping the createStep2(). However, if I just go to page bidbird.test/jobs/create-Step2 the dropzone displays??

<?php

namespace App\Http\Controllers;

use App\Job;
use App\Events\JobPosted;
use Illuminate\Http\Request;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use League\CommonMark\CommonMarkConverter;
use File;


class JobsController extends Controller
{
    protected $job;

    public function __construct()
    {
        $this->middleware('auth')->except(['index', 'show']);
    }
    

    public function index()
    {
        $jobs = Job::oldest()
        ->filter(request(['month', 'year']))
        ->get();

        $archives = Job::selectRaw('year(created_at) year, monthname(created_at) month, count(*) bidded')
            ->groupBy('year', 'month')
            ->orderByRaw('min(created_at) desc')
            ->get()
            ->toArray();

        return view('jobs.index', [
                'jobs' => Job::paginate(3)
            ],
            compact('jobs'));
    }


    public function show(Job $job)
    {
        // $this->authorize('update', $job);
        // \Gate::define('update-job', 'App\Policies\JobPolicy@update');
        // $job = Job::findOrFail(request('job'));
        return view('jobs.show', compact('job'));
    }
    
    /**
     * Show the step 1 Form for creating a new product.
     *
     * @return \Illuminate\Http\Response
     */
    public function createStep1(Request $request)
    {
        $job = $request->session()->get('job');
        return view('jobs.create-Step1', compact('job', $job));
    }

    /**
     * Post Request to store step1 info in session
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function storeStep1(Request $request)
    {
                // dd(request('title'));
        $this->validate(request(), [
            'jobtitle' => 'required|unique:jobs|min:3|max:255',
            'body' => 'required',
            'city' => 'required',
            'state' => 'required',
            'zipcode' => 'required|integer|min:6',
            'for' => 'required',
            'job' => 'required',
            'sub-job' => 'required',
            'deadline' => 'required|integer|between:1,10'
        ]);

        // Calculate deadline; add weeks to current time of job posting
        $week = $request->input('deadline');
        $deadline = Carbon::now()
            ->addWeeks($week)
            ->toDateTimeString();

        // Create a new job using request data
        Job::create([
            'jobtitle' => request('jobtitle'), 
            'body' => request('body'),
            'city' => request('city'),
            'state' => request('state'),
            'zipcode' => request('zipcode'),
            'for' => request('for'),
            'job' => request('job'),
            'sub-job' => request('sub-job'),
            'deadline' => $deadline,
            'user_id' => auth()->id()
        ]);

        // event(new JobPosted($job));
            // Redirect to 
     
        return redirect('/jobs/create-step2');
    }

    /**
     * Post Request to store step1 info in session
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */

    public function createStep2(Request $request)
    {
        $job = $request->session()->get('job');
        return view('jobs.create-Step2', compact('job', $job));
    }

    /**
     * Post Request to store step2 info in session
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */

    public function storeStep2(Request $request)
    {
        $job = $request->session()->get('job');

        
        $job
            ->addMedia($request->file('file'))
            ->toMediaLibrary()
            ->toMediaCollection();

        return redirect()->back()->with('status', 'Media files added to job!');;

    }

    /**
     * Store job
     *
     * @return \Illuminate\Http\Response
     */
    public function store(Request $request)
    {
        $job = $request->session()->get('job');
        $job->save();
        return redirect('/jobs');
    }



    public function edit(Job $job)
    {
        // $job = Job::find($id);
        // abort_if($job->user_id !== auth()->id(), 403);
        return view('jobs.edit', compact('job'));
    }


        public function update(Job $job)
    {
        // dd('hello');
        // $this->authorize('update', $job);
        $job->update(request(['jobtitle', 'body']));
        return redirect('/jobs');
    }


        public function destroy(Job $job)
    {
        // dd('delete ' . $id);
        $job->delete();
        
        return redirect('/jobs');
    }
}

here are the named routes:

Route::get('/jobs/create-Step1', 'JobsController@createStep1');
Route::post('/jobs/create-Step1', 'JobsController@storeStep1');

Route::get('/jobs/create-Step2', 'JobsController@createStep2');
Route::post('/jobs/create-Step2', 'JobsController@storeStep2');

Route::resource('jobs', 'JobsController');

I tried commenting out the ::resource to see if there were double calls, or some issue but it did not seem to have an effect. Almost there!

Snapey's avatar

Why do you need two store steps?

You should have an endpoint for the storage of files and one for the storage of your job.

trevorpan's avatar

@SNAPEY - I thought your idea of having a multi-page job creation was pretty good. Kind of breaks up the process of creating a job in terms of user agony.

Are you saying the storeStep1() should just be store() for the job? and storeStep2() is ok for the media?

If so, perhaps storeStep2() should be renamed to storeJobMedia() to be more clear?

Snapey's avatar

I never suggested multi-page?

I suggested that you create the job before showing the form and then pass it to the view in an edit mode rather than create. You would then have a job ID that you could refer to when saving media.

Your CRUD for a job would normally be 7 controller methods, index then two for new jobs (create/store), editing (show/edit), a show method and then destroy.

This is what I had in mind

public function create()
{

    $job = Job::create([....]);   // placeholders for any required fields

    return $this->edit($job);   // jump straight across into edit since we now have a job
}

public function store()
{
    // never called
{

public function show()
{
    // display the job
}

public function edit(Job $job)
{
    return view('job.edit')->withJob($job);
}

public function update(Request $request, Job $job)
{

    //store the form data

}

and then in your edit form, the url to save the media in dropzone can have the files referencing the already created job.

1 like

Please or to participate in this conversation.