Skip to content

Commit 3860f24

Browse files
committed
feat: #140 use slugs as the service identifiers
1 parent 586ef69 commit 3860f24

File tree

11 files changed

+191
-53
lines changed

11 files changed

+191
-53
lines changed

app/Http/Controllers/ServiceController.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ public function deploy(Service $service, DeploymentData $deploymentData)
118118
$service->deploy($deploymentData);
119119
});
120120

121-
return to_route('services.deployments', ['service' => $service->id]);
121+
return to_route('services.deployments', $service);
122122
}
123123

124124
/**

app/Models/Deployment.php

+1
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ public function resourceLabels(): array
7272
{
7373
return dockerize_labels([
7474
'service.id' => $this->service_id,
75+
'service.slug' => $this->service->slug,
7576
'deployment.id' => $this->id,
7677
]);
7778
}

app/Models/DeploymentData.php

+3
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
use App\Models\DeploymentData\Process;
77
use App\Models\DeploymentData\ReleaseCommand;
88
use App\Models\DeploymentData\SecretVars;
9+
use App\Rules\UniqueInArray;
910
use App\Util\Arrays;
1011
use Illuminate\Validation\ValidationException;
1112
use Spatie\LaravelData\Attributes\DataCollectionOf;
1213
use Spatie\LaravelData\Attributes\Validation\Exists;
14+
use Spatie\LaravelData\Attributes\Validation\Rule;
1315
use Spatie\LaravelData\Data;
1416

1517
class DeploymentData extends Data
@@ -20,6 +22,7 @@ public function __construct(
2022
#[Exists(Node::class, 'id')]
2123
public ?int $placementNodeId,
2224
#[DataCollectionOf(Process::class)]
25+
#[Rule(new UniqueInArray('name'))]
2326
/* @var Process[] */
2427
public array $processes
2528
) {}

app/Models/Service.php

+38
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Illuminate\Database\Eloquent\Relations\HasOne;
1313
use Illuminate\Database\Eloquent\SoftDeletes;
1414
use Illuminate\Support\Facades\Gate;
15+
use Illuminate\Support\Str;
1516

1617
class Service extends Model
1718
{
@@ -25,8 +26,24 @@ class Service extends Model
2526
'team_id',
2627
];
2728

29+
// Add this line to make slug the routing key
30+
public function getRouteKeyName()
31+
{
32+
return 'slug';
33+
}
34+
2835
protected static function booted()
2936
{
37+
static::creating(function (Service $service) {
38+
$service->slug = $service->generateUniqueSlug($service->name);
39+
});
40+
41+
static::updating(function (Service $service) {
42+
if ($service->isDirty('name')) {
43+
$service->slug = $service->generateUniqueSlug($service->name);
44+
}
45+
});
46+
3047
self::saved(function (Service $service) {
3148
if (! $service->docker_name) {
3249
$service->docker_name = $service->makeResourceName($service->name);
@@ -57,6 +74,27 @@ protected static function booted()
5774
});
5875
}
5976

77+
protected function generateUniqueSlug($name)
78+
{
79+
$slug = Str::slug($name);
80+
$originalSlug = $slug;
81+
$vocabulary = ['cat', 'dog', 'bird', 'fish', 'mouse', 'rabbit', 'turtle', 'frog', 'bear', 'lion'];
82+
$adjectives = ['happy', 'brave', 'bright', 'cheerful', 'confident', 'creative', 'determined', 'energetic', 'friendly', 'funny', 'generous', 'kind'];
83+
shuffle($vocabulary);
84+
shuffle($adjectives);
85+
86+
foreach ($adjectives as $adjective) {
87+
foreach ($vocabulary as $word) {
88+
$uniqueSlug = $originalSlug.'-'.$adjective.'-'.$word;
89+
if (! self::where('slug', $uniqueSlug)->where('id', '!=', $this->id)->exists()) {
90+
return $uniqueSlug;
91+
}
92+
}
93+
}
94+
95+
return $slug.'-'.$adjectives[0].'-'.$vocabulary[0].'-'.time();
96+
}
97+
6098
public function swarm(): BelongsTo
6199
{
62100
return $this->belongsTo(Swarm::class);

app/Rules/UniqueInArray.php

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
namespace App\Rules;
4+
5+
use Closure;
6+
use Illuminate\Contracts\Validation\DataAwareRule;
7+
use Illuminate\Contracts\Validation\ValidationRule;
8+
use Override;
9+
10+
class UniqueInArray implements DataAwareRule, ValidationRule
11+
{
12+
protected array $data;
13+
14+
public function __construct(protected string $targetArrayPath) {}
15+
16+
/**
17+
* Run the validation rule.
18+
*
19+
* @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail
20+
*/
21+
public function validate(string $attribute, mixed $value, Closure $fail): void
22+
{
23+
$targetArray = data_get($this->data, $attribute) ?? [];
24+
25+
$unique = collect($targetArray)->unique(fn ($item) => data_get($item, $this->targetArrayPath));
26+
27+
if ($unique->count() !== count($targetArray)) {
28+
$fail('The :attribute must be unique within the specified array.');
29+
}
30+
}
31+
32+
#[Override]
33+
public function setData(array $data): void
34+
{
35+
$this->data = $data;
36+
}
37+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
class AddSlugToServicesTable extends Migration
8+
{
9+
public function up()
10+
{
11+
Schema::table('services', function (Blueprint $table) {
12+
$table->string('slug')->nullable()->after('name');
13+
});
14+
15+
// Fill the slug column with the slugified name
16+
DB::table('services')->whereNull('slug')->update([
17+
'slug' => DB::raw("LOWER(REPLACE(name, ' ', '-'))"),
18+
]);
19+
20+
// Make the slug column unique
21+
Schema::table('services', function (Blueprint $table) {
22+
$table->string('slug')->nullable(false)->change();
23+
});
24+
}
25+
26+
public function down()
27+
{
28+
Schema::table('services', function (Blueprint $table) {
29+
$table->dropColumn('slug');
30+
});
31+
}
32+
}

resources/js/Components/TextInput.vue

+18-9
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,36 @@
11
<script setup>
2-
import {computed, onMounted, ref} from 'vue';
2+
import { computed, onMounted, ref } from "vue";
33
44
const props = defineProps({
55
modelValue: String | Number,
6-
disabled: Boolean,
6+
disabled: Boolean,
7+
readonly: Boolean,
78
});
89
9-
defineEmits(['update:modelValue']);
10+
defineEmits(["update:modelValue"]);
1011
1112
const input = ref(null);
1213
1314
onMounted(() => {
14-
if (input.value.hasAttribute('autofocus')) {
15+
if (input.value.hasAttribute("autofocus")) {
1516
input.value.focus();
1617
}
1718
});
1819
1920
defineExpose({ focus: () => input.value.focus() });
2021
2122
const classes = computed(() => {
22-
const base = 'border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm';
23-
24-
return props.disabled ? `${base} opacity-50 cursor-not-allowed bg-gray-200` : base;
25-
})
23+
const base =
24+
"border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm";
25+
26+
if (props.disabled) {
27+
return `${base} opacity-50 cursor-not-allowed bg-gray-200`;
28+
} else if (props.readonly) {
29+
return `${base} bg-gray-200 cursor-not-allowed`;
30+
} else {
31+
return base;
32+
}
33+
});
2634
</script>
2735

2836
<template>
@@ -31,6 +39,7 @@ const classes = computed(() => {
3139
:class="classes"
3240
:value="modelValue"
3341
:disabled="props.disabled"
42+
:readonly="props.readonly"
3443
@input="$emit('update:modelValue', $event.target.value)"
35-
>
44+
/>
3645
</template>

resources/js/Pages/Services/Index.vue

+2-6
Original file line numberDiff line numberDiff line change
@@ -49,16 +49,12 @@ const props = defineProps({
4949
<NoDataYet v-if="$props.services.length === 0" />
5050

5151
<div class="grid grid-cols-2 gap-4">
52-
<div v-for="service in props.services" :key="service.id">
52+
<div v-for="service in props.services" :key="service.slug">
5353
<div
5454
class="bg-white dark:bg-gray-800 shadow sm:rounded-lg"
5555
>
5656
<Link
57-
:href="
58-
route('services.show', {
59-
service: service.id,
60-
})
61-
"
57+
:href="route('services.show', service)"
6258
class="p-4 grid grid-cols-2"
6359
>
6460
<div class="flex flex-col">

resources/js/Pages/Services/Partials/ServiceDetailsForm.vue

+7-2
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@
22
import TextInput from "@/Components/TextInput.vue";
33
import TeamCard from "@/Components/TeamCard.vue";
44
import FormField from "@/Components/FormField.vue";
5-
import Select from "@/Components/Select.vue";
6-
import { effect, reactive } from "vue";
75
86
const model = defineModel();
97
108
const props = defineProps({
119
team: Object,
10+
service: Object,
1211
});
1312
</script>
1413

@@ -20,4 +19,10 @@ const props = defineProps({
2019

2120
<TextInput v-model="model.name" class="block w-full" />
2221
</FormField>
22+
23+
<FormField v-if="service" class="col-span-2">
24+
<template #label>Service Slug</template>
25+
26+
<TextInput :value="props.service.slug" class="block w-full" readonly />
27+
</FormField>
2328
</template>

resources/js/Pages/Services/Show.vue

+6-11
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,7 @@ import DeploymentData from "@/Pages/Services/Partials/DeploymentData.vue";
66
import ServiceDetailsForm from "@/Pages/Services/Partials/ServiceDetailsForm.vue";
77
import FormSection from "@/Components/FormSection.vue";
88
import SectionBorder from "@/Components/SectionBorder.vue";
9-
import DangerButton from "@/Components/DangerButton.vue";
10-
import TextInput from "@/Components/TextInput.vue";
11-
import DialogModal from "@/Components/DialogModal.vue";
12-
import InputError from "@/Components/InputError.vue";
13-
import SecondaryButton from "@/Components/SecondaryButton.vue";
14-
import { nextTick, reactive, ref } from "vue";
15-
import ActionSection from "@/Components/ActionSection.vue";
9+
import { ref } from "vue";
1610
import DeleteResourceSection from "@/Components/DeleteResourceSection.vue";
1711
1812
const props = defineProps({
@@ -27,21 +21,21 @@ const serviceForm = useForm({
2721
});
2822
2923
const updateService = () => {
30-
serviceForm.put(route("services.update", props.service.id));
24+
serviceForm.put(route("services.update", props.service));
3125
};
3226
3327
const deploymentForm = useForm(props.service.latest_deployment.data);
3428
3529
const deploy = () => {
36-
deploymentForm.post(route("services.deploy", props.service.id));
30+
deploymentForm.post(route("services.deploy", props.service));
3731
};
3832
3933
const deletionForm = useForm({
4034
serviceName: "",
4135
});
4236
4337
const destroyService = () => {
44-
return deletionForm.delete(route("services.destroy", props.service.id));
38+
return deletionForm.delete(route("services.destroy", props.service));
4539
};
4640
4741
const serviceDeleteInput = ref(null);
@@ -66,7 +60,8 @@ const serviceDeleteInput = ref(null);
6660
<template #form>
6761
<ServiceDetailsForm
6862
v-model="serviceForm"
69-
:team="$props.service.team"
63+
:team="props.service.team"
64+
:service="props.service"
7065
/>
7166
</template>
7267

0 commit comments

Comments
 (0)