diff --git a/app/Console/Commands/ConvertTraitSubtype.php b/app/Console/Commands/ConvertTraitSubtype.php new file mode 100644 index 0000000000..81b0e89bd3 --- /dev/null +++ b/app/Console/Commands/ConvertTraitSubtype.php @@ -0,0 +1,74 @@ +info('This command will not execute, as it has already been run.'); + + return; + } + + $features = Feature::where(function ($query) { + $query->whereNotNull('subtype_id'); + })->get(); + + $this->info('Converting '.count($features).' features\' subtypes...'); + $bar = $this->output->createProgressBar(count($features)); + foreach ($features as $feature) { + if ($feature->subtype_id) { + FeatureSubtype::create([ + 'feature_id' => $feature->id, + 'subtype_id' => $feature->subtype_id, + ]); + } + $bar->advance(); + } + + $bar->finish(); + $this->info("\n".'Dropping subtype ID column from the feature table...'); + + Schema::table('features', function (Blueprint $table) { + $table->dropColumn('subtype_id'); + }); + + $this->info('Done!'); + } else { + $this->info('Required table does not exist, please run migrations first.'); + } + } +} diff --git a/app/Http/Controllers/Admin/Data/FeatureController.php b/app/Http/Controllers/Admin/Data/FeatureController.php index 588caad6ff..ec9ec1131a 100644 --- a/app/Http/Controllers/Admin/Data/FeatureController.php +++ b/app/Http/Controllers/Admin/Data/FeatureController.php @@ -184,9 +184,11 @@ public function getFeatureIndex(Request $request) { } if (isset($data['subtype_id']) && $data['subtype_id'] != 'none') { if ($data['subtype_id'] == 'withoutOption') { - $query->whereNull('subtype_id'); + $query->subtypes->isEmpty(); } else { - $query->where('subtype_id', $data['subtype_id']); + $query->whereHas('subtypes', function ($query) use ($data) { + $query->where('subtype_id', $data['subtype_id']); + }); } } if (isset($data['name'])) { @@ -253,7 +255,7 @@ public function getCreateFeature() { 'feature' => new Feature, 'rarities' => ['none' => 'Select a Rarity'] + Rarity::orderBy('sort', 'DESC')->pluck('name', 'id')->toArray(), 'specieses' => ['none' => 'No restriction'] + Species::orderBy('sort', 'DESC')->pluck('name', 'id')->toArray(), - 'subtypes' => ['none' => 'No subtype'] + Subtype::orderBy('sort', 'DESC')->pluck('name', 'id')->toArray(), + 'subtypes' => Subtype::orderBy('sort', 'DESC')->pluck('name', 'id')->toArray(), 'categories' => ['none' => 'No category'] + FeatureCategory::orderBy('sort', 'DESC')->pluck('name', 'id')->toArray(), ]); } @@ -275,7 +277,7 @@ public function getEditFeature($id) { 'feature' => $feature, 'rarities' => ['none' => 'Select a Rarity'] + Rarity::orderBy('sort', 'DESC')->pluck('name', 'id')->toArray(), 'specieses' => ['none' => 'No restriction'] + Species::orderBy('sort', 'DESC')->pluck('name', 'id')->toArray(), - 'subtypes' => ['none' => 'No subtype'] + Subtype::orderBy('sort', 'DESC')->pluck('name', 'id')->toArray(), + 'subtypes' => Subtype::orderBy('sort', 'DESC')->pluck('name', 'id')->toArray(), 'categories' => ['none' => 'No category'] + FeatureCategory::orderBy('sort', 'DESC')->pluck('name', 'id')->toArray(), ]); } @@ -291,7 +293,7 @@ public function getEditFeature($id) { public function postCreateEditFeature(Request $request, FeatureService $service, $id = null) { $id ? $request->validate(Feature::$updateRules) : $request->validate(Feature::$createRules); $data = $request->only([ - 'name', 'species_id', 'subtype_id', 'rarity_id', 'feature_category_id', 'description', 'image', 'remove_image', 'is_visible', + 'name', 'species_id', 'subtype_ids', 'rarity_id', 'feature_category_id', 'description', 'image', 'remove_image', 'is_visible', ]); if ($id && $service->updateFeature(Feature::find($id), $data, Auth::user())) { flash('Trait updated successfully.')->success(); @@ -350,11 +352,11 @@ public function postDeleteFeature(Request $request, FeatureService $service, $id */ public function getCreateEditFeatureSubtype(Request $request) { $species = $request->input('species'); - $subtype_id = $request->input('subtype_id'); + $subtype_ids = $request->input('subtype_ids'); return view('admin.features._create_edit_feature_subtype', [ - 'subtypes' => ['0' => 'Select Subtype'] + Subtype::where('species_id', '=', $species)->orderBy('sort', 'DESC')->pluck('name', 'id')->toArray(), - 'subtype_id' => $subtype_id, + 'subtypes' => Subtype::where('species_id', '=', $species)->orderBy('sort', 'DESC')->pluck('name', 'id')->toArray(), + 'subtype_ids' => $subtype_ids, ]); } } diff --git a/app/Http/Controllers/WorldController.php b/app/Http/Controllers/WorldController.php index 1c4dbef7da..78d7cab4cc 100644 --- a/app/Http/Controllers/WorldController.php +++ b/app/Http/Controllers/WorldController.php @@ -264,7 +264,7 @@ public function getFeatureCategories(Request $request) { * @return \Illuminate\Contracts\Support\Renderable */ public function getFeatures(Request $request) { - $query = Feature::visible(Auth::user() ?? null)->with('category', 'rarity', 'species', 'subtype'); + $query = Feature::visible(Auth::user() ?? null)->with('category', 'rarity', 'species', 'subtypes'); $data = $request->only(['rarity_id', 'feature_category_id', 'species_id', 'subtype_id', 'name', 'sort']); @@ -287,9 +287,13 @@ public function getFeatures(Request $request) { } if (isset($data['subtype_id']) && $data['subtype_id'] != 'none') { if ($data['subtype_id'] == 'withoutOption') { - $query->whereNull('subtype_id'); + $query->doesntHave('subtypes'); } else { - $query->where('subtype_id', $data['subtype_id']); + if (isset($data['subtype_id']) && $data['subtype_id'] != 'none') { + $query->whereHas('subtypes', function ($query) use ($data) { + $query->where('subtype_id', $data['subtype_id']); + }); + } } } if (isset($data['name'])) { @@ -358,15 +362,20 @@ public function getSpeciesFeatures($id) { abort(404); } - $features = $species->features()->visible(Auth::user() ?? null)->with('rarity', 'subtype'); + $features = $species->features()->visible(Auth::user() ?? null)->with('rarity', 'subtypes'); $features = count($categories) ? $features->orderByRaw('FIELD(feature_category_id,'.implode(',', $categories->pluck('id')->toArray()).')') : $features; $features = $features->orderByRaw('FIELD(rarity_id,'.implode(',', $rarities->pluck('id')->toArray()).')') ->orderBy('has_image', 'DESC') ->orderBy('name') - ->get()->filter(function ($feature) { - return $feature->subtype?->is_visible !== 0; + ->get() + ->filter(function ($feature) { + if (!$feature->subtypes->isEmpty()) { + return !$feature->subtypes->where('is_visible', true)->isEmpty(); + } + + return true; }) ->groupBy(['feature_category_id', 'id']); @@ -413,7 +422,7 @@ public function getSubtypeFeatures($id, Request $request) { } else { $features = $features ->filter(function ($feature) use ($subtype) { - return !($feature->subtype && $feature->subtype->id != $subtype->id); + return $feature->subtypes->isEmpty() || !$feature->subtypes->where('id', $subtype->id)->isEmpty(); }) ->groupBy(['feature_category_id', 'id']); } diff --git a/app/Models/Feature/Feature.php b/app/Models/Feature/Feature.php index 253389abd8..d9f1052ed0 100644 --- a/app/Models/Feature/Feature.php +++ b/app/Models/Feature/Feature.php @@ -15,7 +15,7 @@ class Feature extends Model { * @var array */ protected $fillable = [ - 'feature_category_id', 'species_id', 'subtype_id', 'rarity_id', 'name', 'has_image', 'description', 'parsed_description', 'is_visible', 'hash', + 'feature_category_id', 'species_id', 'rarity_id', 'name', 'has_image', 'description', 'parsed_description', 'is_visible', 'hash', ]; /** @@ -32,7 +32,6 @@ class Feature extends Model { public static $createRules = [ 'feature_category_id' => 'nullable', 'species_id' => 'nullable', - 'subtype_id' => 'nullable', 'rarity_id' => 'required|exists:rarities,id', 'name' => 'required|unique:features|between:3,100', 'description' => 'nullable', @@ -47,7 +46,6 @@ class Feature extends Model { public static $updateRules = [ 'feature_category_id' => 'nullable', 'species_id' => 'nullable', - 'subtype_id' => 'nullable', 'rarity_id' => 'required|exists:rarities,id', 'name' => 'required|between:3,100', 'description' => 'nullable', @@ -75,17 +73,17 @@ public function species() { } /** - * Get the subtype the feature belongs to. + * Get the category the feature belongs to. */ - public function subtype() { - return $this->belongsTo(Subtype::class); + public function category() { + return $this->belongsTo(FeatureCategory::class, 'feature_category_id'); } /** - * Get the category the feature belongs to. + * Get the subtypes of this feature. */ - public function category() { - return $this->belongsTo(FeatureCategory::class, 'feature_category_id'); + public function subtypes() { + return $this->belongsToMany(Subtype::class, 'feature_subtypes'); } /********************************************************************************************** @@ -144,7 +142,14 @@ public function scopeSortSpecies($query) { public function scopeSortSubtype($query) { $ids = Subtype::orderBy('sort', 'DESC')->pluck('id')->toArray(); - return count($ids) ? $query->orderBy(DB::raw('FIELD(subtype_id, '.implode(',', $ids).')')) : $query; + if (count($ids)) { + $ordered_subtypes = $this->subtypes()->latest('id')->pluck('id')->toArray(); + foreach ($ordered_subtypes as $subtype) { + return $query->with('feature_subtypes')->orderBy(DB::raw($subtype.', '.implode(',', $ids).')')); + } + } + + return $query; } /** @@ -286,6 +291,32 @@ public function getAdminPowerAttribute() { **********************************************************************************************/ + /** + * Gets the trait's subtypes that are visible to the current user. + * + * @param mixed|null $user + */ + public function getSubtypes($user = null) { + return $this->subtypes()->visible($user)->get(); + } + + /** + * Displays the trait's subtypes as an imploded string. + * + * @param mixed|null $user + */ + public function displaySubtypes($user = null) { + if (!count($this->subtypes()->visible($user)->get())) { + return 'None'; + } + $subtypes = []; + foreach ($this->subtypes()->visible($user)->get() as $subtype) { + $subtypes[] = $subtype->displayName; + } + + return implode(', ', $subtypes); + } + public static function getDropdownItems($withHidden = 0) { $visibleOnly = 1; if ($withHidden) { @@ -296,7 +327,7 @@ public static function getDropdownItems($withHidden = 0) { $sorted_feature_categories = collect(FeatureCategory::all()->where('is_visible', '>=', $visibleOnly)->sortBy('sort')->pluck('name')->toArray()); $grouped = self::where('is_visible', '>=', $visibleOnly) - ->select('name', 'id', 'feature_category_id', 'rarity_id', 'species_id', 'subtype_id')->with(['category', 'rarity', 'species', 'subtype']) + ->select('name', 'id', 'feature_category_id', 'rarity_id', 'species_id')->with(['category', 'rarity', 'species', 'subtypes']) ->orderBy('name')->get()->keyBy('id')->groupBy('category.name', $preserveKeys = true) ->toArray(); if (isset($grouped[''])) { @@ -336,8 +367,13 @@ public static function getDropdownItems($withHidden = 0) { : '' ). ( - config('lorekeeper.extensions.organised_traits_dropdown.display_subtype') && $feature['subtype_id'] ? - ' ('.$feature['subtype']['name'].')' + config('lorekeeper.extensions.organised_traits_dropdown.display_subtype') && count($feature['subtypes']) ? + ' ('.implode(', ', array_map( + function (array $subtype) { + return $subtype['name']; + }, + $feature['subtypes'] + )).')' : '' ). ( // rarity diff --git a/app/Models/Feature/FeatureSubtype.php b/app/Models/Feature/FeatureSubtype.php new file mode 100644 index 0000000000..2642f88e9c --- /dev/null +++ b/app/Models/Feature/FeatureSubtype.php @@ -0,0 +1,51 @@ +belongsTo(Feature::class, 'feature_id'); + } + + /** + * Get the subtype associated with this record. + */ + public function subtype() { + return $this->belongsTo(Subtype::class, 'subtype_id'); + } +} diff --git a/app/Models/Species/Subtype.php b/app/Models/Species/Subtype.php index 9a78fb15b7..e79dfbdd6b 100644 --- a/app/Models/Species/Subtype.php +++ b/app/Models/Species/Subtype.php @@ -72,7 +72,7 @@ public function species() { * Get the features associated with this subtype. */ public function features() { - return $this->hasMany(Feature::class); + return $this->belongsToMany(Feature::class, 'feature_subtypes'); } /********************************************************************************************** diff --git a/app/Services/FeatureService.php b/app/Services/FeatureService.php index a7ede66b7c..13736baa2b 100644 --- a/app/Services/FeatureService.php +++ b/app/Services/FeatureService.php @@ -200,24 +200,26 @@ public function createFeature($data, $user) { if (isset($data['species_id']) && $data['species_id'] == 'none') { $data['species_id'] = null; } - if (isset($data['subtype_id']) && $data['subtype_id'] == 'none') { - $data['subtype_id'] = null; - } - if ((isset($data['feature_category_id']) && $data['feature_category_id']) && !FeatureCategory::where('id', $data['feature_category_id'])->exists()) { throw new \Exception('The selected trait category is invalid.'); } if ((isset($data['species_id']) && $data['species_id']) && !Species::where('id', $data['species_id'])->exists()) { throw new \Exception('The selected species is invalid.'); } - if (isset($data['subtype_id']) && $data['subtype_id']) { - $subtype = Subtype::find($data['subtype_id']); + if (isset($data['subtype_ids']) && $data['subtype_ids']) { + $subtype = Subtype::find($data['subtype_ids']); if (!(isset($data['species_id']) && $data['species_id'])) { throw new \Exception('Species must be selected to select a subtype.'); } - if (!$subtype || $subtype->species_id != $data['species_id']) { - throw new \Exception('Selected subtype invalid or does not match species.'); + + foreach ($data['subtype_ids'] as $subtypeId) { + $subtype = Subtype::find($subtypeId); + if (!$subtype || $subtype->species_id != $data['species_id']) { + throw new \Exception('Selected subtype invalid or does not match species.'); + } } + } else { + $data['subtype_ids'] = []; } $data = $this->populateData($data); @@ -234,6 +236,10 @@ public function createFeature($data, $user) { $feature = Feature::create($data); + foreach ($data['subtype_ids'] as $subtypeId) { + $feature->subtypes()->attach($subtypeId); + } + if (!$this->logAdminAction($user, 'Created Feature', 'Created '.$feature->displayName)) { throw new \Exception('Failed to log admin action.'); } @@ -269,9 +275,6 @@ public function updateFeature($feature, $data, $user) { if (isset($data['species_id']) && $data['species_id'] == 'none') { $data['species_id'] = null; } - if (isset($data['subtype_id']) && $data['subtype_id'] == 'none') { - $data['subtype_id'] = null; - } // More specific validation if (Feature::where('name', $data['name'])->where('id', '!=', $feature->id)->exists()) { @@ -283,18 +286,32 @@ public function updateFeature($feature, $data, $user) { if ((isset($data['species_id']) && $data['species_id']) && !Species::where('id', $data['species_id'])->exists()) { throw new \Exception('The selected species is invalid.'); } - if (isset($data['subtype_id']) && $data['subtype_id']) { - $subtype = Subtype::find($data['subtype_id']); + if (isset($data['subtype_ids']) && $data['subtype_ids']) { + $subtype = Subtype::find($data['subtype_ids']); if (!(isset($data['species_id']) && $data['species_id'])) { throw new \Exception('Species must be selected to select a subtype.'); } - if (!$subtype || $subtype->species_id != $data['species_id']) { - throw new \Exception('Selected subtype invalid or does not match species.'); + + foreach ($data['subtype_ids'] as $subtypeId) { + $subtype = Subtype::find($subtypeId); + if (!$subtype || $subtype->species_id != $data['species_id']) { + throw new \Exception('Selected subtype invalid or does not match species.'); + } } + } else { + $data['subtype_ids'] = []; } $data = $this->populateData($data); + // remove old subtypes + $feature->subtypes()->detach(); + if (isset($data['subtype_ids']) && $data['subtype_ids']) { + foreach ($data['subtype_ids'] as $subtypeId) { + $feature->subtypes()->attach($subtypeId); + } + } + $image = null; if (isset($data['image']) && $data['image']) { $data['has_image'] = 1; @@ -342,6 +359,8 @@ public function deleteFeature($feature, $user) { throw new \Exception('Failed to log admin action.'); } + $feature->subtypes()->detach(); + if ($feature->has_image) { $this->deleteImage($feature->imagePath, $feature->imageFileName); } diff --git a/database/migrations/2025_07_10_193208_convert_trait_subtypes.php b/database/migrations/2025_07_10_193208_convert_trait_subtypes.php new file mode 100644 index 0000000000..5c3188dc47 --- /dev/null +++ b/database/migrations/2025_07_10_193208_convert_trait_subtypes.php @@ -0,0 +1,28 @@ +integer('feature_id')->unsigned(); + $table->integer('subtype_id')->unsigned(); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void { + // + Schema::dropIfExists('feature_subtypes'); + } +}; diff --git a/resources/views/admin/features/_create_edit_feature_subtype.blade.php b/resources/views/admin/features/_create_edit_feature_subtype.blade.php index 2f571e001f..c69f35f7d6 100644 --- a/resources/views/admin/features/_create_edit_feature_subtype.blade.php +++ b/resources/views/admin/features/_create_edit_feature_subtype.blade.php @@ -1,2 +1,2 @@ -{!! Form::label('Subtype (Optional)') !!} {!! add_help('This is cosmetic and does not limit choice of traits in selections.') !!} -{!! Form::select('subtype_id', $subtypes, $subtype_id, ['class' => 'form-control', 'id' => 'subtype']) !!} +{!! Form::label('Subtypes (Optional)') !!} {!! add_help('This is cosmetic and does not limit choice of traits in selections.') !!} +{!! Form::select('subtype_ids[]', $subtypes, $subtype_ids, ['class' => 'form-control', 'id' => 'subtype', 'multiple', 'placeholder' => 'Pick a species first.']) !!} diff --git a/resources/views/admin/features/create_edit_feature.blade.php b/resources/views/admin/features/create_edit_feature.blade.php index 4fa315c9f6..72663b1feb 100644 --- a/resources/views/admin/features/create_edit_feature.blade.php +++ b/resources/views/admin/features/create_edit_feature.blade.php @@ -53,8 +53,8 @@ {!! Form::select('species_id', $specieses, $feature->species_id, ['class' => 'form-control', 'id' => 'species']) !!}
- {!! Form::label('Subtype (Optional)') !!} {!! add_help('This is cosmetic and does not limit choice of traits in selections.') !!} - {!! Form::select('subtype_id', $subtypes, $feature->subtype_id, ['class' => 'form-control', 'id' => 'subtype']) !!} + {!! Form::label('Subtypes (Optional)') !!} {!! add_help('This is cosmetic and does not limit choice of traits in selections.') !!} + {!! Form::select('subtype_ids[]', $subtypes, $feature->subtypes, ['class' => 'form-control', 'id' => 'subtype', 'multiple', 'placeholder' => 'Pick a species first.']) !!}
@@ -101,16 +101,19 @@ function refreshSubtype() { var species = $('#species').val(); - var subtype_id = {{ $feature->subtype_id ?: 'null' }}; + var subtype_ids = @json($feature->subtypes->pluck('id')->toArray()); $.ajax({ type: "GET", - url: "{{ url('admin/data/traits/check-subtype') }}?species=" + species + "&subtype_id=" + subtype_id, + url: "{{ url('admin/data/traits/check-subtype') }}?species=" + species + "&subtype_ids=" + subtype_ids, dataType: "text" }).done(function(res) { $("#subtypes").html(res); + $("#subtype").selectize(); }).fail(function(jqXHR, textStatus, errorThrown) { alert("AJAX call failed: " + textStatus + ", " + errorThrown); }); }; + + $('#subtype').selectize(); @endsection diff --git a/resources/views/admin/features/features.blade.php b/resources/views/admin/features/features.blade.php index 7f6766d60e..55dc5a5ce2 100644 --- a/resources/views/admin/features/features.blade.php +++ b/resources/views/admin/features/features.blade.php @@ -119,7 +119,7 @@
{{ $feature->species ? $feature->species->name : '---' }}
-
{{ $feature->subtype ? $feature->subtype->name : '---' }}
+
{!! $feature->displaySubtypes(Auth::User() ?? null) !!}
Edit
diff --git a/resources/views/pages/credits.blade.php b/resources/views/pages/credits.blade.php index c30b58ac7c..5536830075 100644 --- a/resources/views/pages/credits.blade.php +++ b/resources/views/pages/credits.blade.php @@ -60,6 +60,9 @@

Masterlist Sublists by Junijwi

+

+ Multiple Trait Subtypes by Fulmine +

MYO Item Tag by Junijwi

diff --git a/resources/views/world/_feature_entry.blade.php b/resources/views/world/_feature_entry.blade.php index 71eee4909a..327815c46f 100644 --- a/resources/views/world/_feature_entry.blade.php +++ b/resources/views/world/_feature_entry.blade.php @@ -25,8 +25,8 @@ @if ($feature->species_id)
Species: {!! $feature->species->displayName !!} - @if ($feature->subtype_id) - ({!! $feature->subtype->displayName !!} subtype) + @if (count($feature->getSubtypes(Auth::User() ?? null))) + ({!! $feature->displaySubtypes(Auth::User() ?? null) !!}) @endif
@endif diff --git a/resources/views/world/_features_index.blade.php b/resources/views/world/_features_index.blade.php index d3a0e98826..896a4590ff 100644 --- a/resources/views/world/_features_index.blade.php +++ b/resources/views/world/_features_index.blade.php @@ -22,8 +22,8 @@ @endif {!! $feature->first()->displayName !!} - @if ($showSubtype && $feature->first()->subtype) -
({!! $feature->first()->subtype->displayName !!} Subtype) + @if ($showSubtype && count($feature->first()->getSubtypes(Auth::User() ?? null))) +
(Subtype{{ count($feature->first()->getSubtypes(Auth::User() ?? null)) > 1 ? 's' : '' }}: {!! $feature->first()->displaySubtypes(Auth::User() ?? null) !!}) @endif