mirror of
https://github.com/vitodeploy/vito.git
synced 2025-07-01 05:56:16 +00:00
Compare commits
6 Commits
Author | SHA1 | Date | |
---|---|---|---|
7f5e68e131 | |||
431da1b728 | |||
8c487a64fa | |||
a67e586a5d | |||
960db714b7 | |||
7da0221ccb |
58
app/Actions/Tag/AttachTag.php
Normal file
58
app/Actions/Tag/AttachTag.php
Normal file
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Tag;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\Site;
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class AttachTag
|
||||
{
|
||||
public function attach(User $user, array $input): Tag
|
||||
{
|
||||
$this->validate($input);
|
||||
|
||||
/** @var Server|Site $taggable */
|
||||
$taggable = $input['taggable_type']::findOrFail($input['taggable_id']);
|
||||
|
||||
$tag = Tag::query()->where('name', $input['name'])->first();
|
||||
if ($tag) {
|
||||
if (! $taggable->tags->contains($tag->id)) {
|
||||
$taggable->tags()->attach($tag->id);
|
||||
}
|
||||
|
||||
return $tag;
|
||||
}
|
||||
|
||||
$tag = new Tag([
|
||||
'project_id' => $user->currentProject->id,
|
||||
'name' => $input['name'],
|
||||
'color' => config('core.tag_colors')[array_rand(config('core.tag_colors'))],
|
||||
]);
|
||||
$tag->save();
|
||||
|
||||
$taggable->tags()->attach($tag->id);
|
||||
|
||||
return $tag;
|
||||
}
|
||||
|
||||
private function validate(array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => [
|
||||
'required',
|
||||
],
|
||||
'taggable_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
],
|
||||
'taggable_type' => [
|
||||
'required',
|
||||
Rule::in(config('core.taggable_types')),
|
||||
],
|
||||
])->validate();
|
||||
}
|
||||
}
|
49
app/Actions/Tag/CreateTag.php
Normal file
49
app/Actions/Tag/CreateTag.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Tag;
|
||||
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class CreateTag
|
||||
{
|
||||
public function create(User $user, array $input): Tag
|
||||
{
|
||||
$this->validate($input);
|
||||
|
||||
$tag = Tag::query()
|
||||
->where('project_id', $user->current_project_id)
|
||||
->where('name', $input['name'])
|
||||
->first();
|
||||
if ($tag) {
|
||||
throw ValidationException::withMessages([
|
||||
'name' => ['Tag with this name already exists.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$tag = new Tag([
|
||||
'project_id' => $user->currentProject->id,
|
||||
'name' => $input['name'],
|
||||
'color' => $input['color'],
|
||||
]);
|
||||
$tag->save();
|
||||
|
||||
return $tag;
|
||||
}
|
||||
|
||||
private function validate(array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => [
|
||||
'required',
|
||||
],
|
||||
'color' => [
|
||||
'required',
|
||||
Rule::in(config('core.tag_colors')),
|
||||
],
|
||||
])->validate();
|
||||
}
|
||||
}
|
15
app/Actions/Tag/DeleteTag.php
Normal file
15
app/Actions/Tag/DeleteTag.php
Normal file
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Tag;
|
||||
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeleteTag
|
||||
{
|
||||
public function delete(Tag $tag): void
|
||||
{
|
||||
DB::table('taggables')->where('tag_id', $tag->id)->delete();
|
||||
$tag->delete();
|
||||
}
|
||||
}
|
36
app/Actions/Tag/DetachTag.php
Normal file
36
app/Actions/Tag/DetachTag.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Tag;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\Site;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class DetachTag
|
||||
{
|
||||
public function detach(Tag $tag, array $input): void
|
||||
{
|
||||
$this->validate($input);
|
||||
|
||||
/** @var Server|Site $taggable */
|
||||
$taggable = $input['taggable_type']::findOrFail($input['taggable_id']);
|
||||
|
||||
$taggable->tags()->detach($tag->id);
|
||||
}
|
||||
|
||||
private function validate(array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'taggable_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
],
|
||||
'taggable_type' => [
|
||||
'required',
|
||||
Rule::in(config('core.taggable_types')),
|
||||
],
|
||||
])->validate();
|
||||
}
|
||||
}
|
38
app/Actions/Tag/EditTag.php
Normal file
38
app/Actions/Tag/EditTag.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Tag;
|
||||
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EditTag
|
||||
{
|
||||
public function edit(Tag $tag, array $input): void
|
||||
{
|
||||
$this->validate($input);
|
||||
|
||||
$tag->name = $input['name'];
|
||||
$tag->color = $input['color'];
|
||||
|
||||
$tag->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
private function validate(array $input): void
|
||||
{
|
||||
$rules = [
|
||||
'name' => [
|
||||
'required',
|
||||
],
|
||||
'color' => [
|
||||
'required',
|
||||
Rule::in(config('core.tag_colors')),
|
||||
],
|
||||
];
|
||||
Validator::make($input, $rules)->validate();
|
||||
}
|
||||
}
|
30
app/Facades/FTP.php
Normal file
30
app/Facades/FTP.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Facades;
|
||||
|
||||
use App\Support\Testing\FTPFake;
|
||||
use FTP\Connection;
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
|
||||
/**
|
||||
* @method static bool|Connection connect(string $host, string $port, bool $ssl = false)
|
||||
* @method static bool login(string $username, string $password, bool|Connection $connection)
|
||||
* @method static void close(bool|Connection $connection)
|
||||
* @method static bool passive(bool|Connection $connection, bool $passive)
|
||||
* @method static bool delete(bool|Connection $connection, string $path)
|
||||
* @method static void assertConnected(string $host)
|
||||
*/
|
||||
class FTP extends Facade
|
||||
{
|
||||
protected static function getFacadeAccessor(): string
|
||||
{
|
||||
return 'ftp';
|
||||
}
|
||||
|
||||
public static function fake(): FTPFake
|
||||
{
|
||||
static::swap($fake = new FTPFake());
|
||||
|
||||
return $fake;
|
||||
}
|
||||
}
|
37
app/Helpers/FTP.php
Normal file
37
app/Helpers/FTP.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use FTP\Connection;
|
||||
|
||||
class FTP
|
||||
{
|
||||
public function connect(string $host, string $port, bool $ssl = false): bool|Connection
|
||||
{
|
||||
if ($ssl) {
|
||||
return ftp_ssl_connect($host, $port, 5);
|
||||
}
|
||||
|
||||
return ftp_connect($host, $port, 5);
|
||||
}
|
||||
|
||||
public function login(string $username, string $password, bool|Connection $connection): bool
|
||||
{
|
||||
return ftp_login($connection, $username, $password);
|
||||
}
|
||||
|
||||
public function close(bool|Connection $connection): void
|
||||
{
|
||||
ftp_close($connection);
|
||||
}
|
||||
|
||||
public function passive(bool|Connection $connection, bool $passive): bool
|
||||
{
|
||||
return ftp_pasv($connection, $passive);
|
||||
}
|
||||
|
||||
public function delete(bool|Connection $connection, string $path): bool
|
||||
{
|
||||
return ftp_delete($connection, $path);
|
||||
}
|
||||
}
|
90
app/Http/Controllers/Settings/TagController.php
Normal file
90
app/Http/Controllers/Settings/TagController.php
Normal file
@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Actions\Tag\AttachTag;
|
||||
use App\Actions\Tag\CreateTag;
|
||||
use App\Actions\Tag\DeleteTag;
|
||||
use App\Actions\Tag\DetachTag;
|
||||
use App\Actions\Tag\EditTag;
|
||||
use App\Facades\Toast;
|
||||
use App\Helpers\HtmxResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TagController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$data = [
|
||||
'tags' => Tag::getByProjectId(auth()->user()->current_project_id)->get(),
|
||||
];
|
||||
|
||||
if ($request->has('edit')) {
|
||||
$data['editTag'] = Tag::find($request->input('edit'));
|
||||
}
|
||||
|
||||
return view('settings.tags.index', $data);
|
||||
}
|
||||
|
||||
public function create(Request $request): HtmxResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
app(CreateTag::class)->create(
|
||||
$user,
|
||||
$request->input(),
|
||||
);
|
||||
|
||||
Toast::success('Tag created.');
|
||||
|
||||
return htmx()->redirect(route('settings.tags'));
|
||||
}
|
||||
|
||||
public function update(Tag $tag, Request $request): HtmxResponse
|
||||
{
|
||||
app(EditTag::class)->edit(
|
||||
$tag,
|
||||
$request->input(),
|
||||
);
|
||||
|
||||
Toast::success('Tag updated.');
|
||||
|
||||
return htmx()->redirect(route('settings.tags'));
|
||||
}
|
||||
|
||||
public function attach(Request $request): RedirectResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
app(AttachTag::class)->attach($user, $request->input());
|
||||
|
||||
return back()->with([
|
||||
'status' => 'tag-created',
|
||||
]);
|
||||
}
|
||||
|
||||
public function detach(Request $request, Tag $tag): RedirectResponse
|
||||
{
|
||||
app(DetachTag::class)->detach($tag, $request->input());
|
||||
|
||||
return back()->with([
|
||||
'status' => 'tag-detached',
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete(Tag $tag): RedirectResponse
|
||||
{
|
||||
app(DeleteTag::class)->delete($tag);
|
||||
|
||||
Toast::success('Tag deleted.');
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
@ -65,4 +65,9 @@ public function sourceControls(): HasMany
|
||||
{
|
||||
return $this->hasMany(SourceControl::class);
|
||||
}
|
||||
|
||||
public function tags(): HasMany
|
||||
{
|
||||
return $this->hasMany(Tag::class);
|
||||
}
|
||||
}
|
||||
|
@ -14,6 +14,7 @@
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphToMany;
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@ -214,6 +215,11 @@ public function sshKeys(): BelongsToMany
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function tags(): MorphToMany
|
||||
{
|
||||
return $this->morphToMany(Tag::class, 'taggable');
|
||||
}
|
||||
|
||||
public function getSshUser(): string
|
||||
{
|
||||
if ($this->ssh_user) {
|
||||
|
@ -11,6 +11,7 @@
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphToMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
@ -126,6 +127,11 @@ public function ssls(): HasMany
|
||||
return $this->hasMany(Ssl::class);
|
||||
}
|
||||
|
||||
public function tags(): MorphToMany
|
||||
{
|
||||
return $this->morphToMany(Tag::class, 'taggable');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws SourceControlIsNotConnected
|
||||
*/
|
||||
|
55
app/Models/Tag.php
Normal file
55
app/Models/Tag.php
Normal file
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphToMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $project_id
|
||||
* @property string $name
|
||||
* @property string $color
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
class Tag extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'project_id',
|
||||
'name',
|
||||
'color',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'project_id' => 'int',
|
||||
];
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function servers(): MorphToMany
|
||||
{
|
||||
return $this->morphedByMany(Server::class, 'taggable');
|
||||
}
|
||||
|
||||
public function sites(): MorphToMany
|
||||
{
|
||||
return $this->morphedByMany(Site::class, 'taggable');
|
||||
}
|
||||
|
||||
public static function getByProjectId(int $projectId): Builder
|
||||
{
|
||||
return self::query()
|
||||
->where('project_id', $projectId)
|
||||
->orWhereNull('project_id');
|
||||
}
|
||||
}
|
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Helpers\FTP;
|
||||
use App\Helpers\Notifier;
|
||||
use App\Helpers\SSH;
|
||||
use App\Helpers\Toast;
|
||||
@ -36,5 +37,8 @@ public function boot(): void
|
||||
$this->app->bind('toast', function () {
|
||||
return new Toast;
|
||||
});
|
||||
$this->app->bind('ftp', function () {
|
||||
return new FTP;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -41,7 +41,7 @@ public function connect(): bool
|
||||
$isConnected = $connection && $this->login($connection);
|
||||
|
||||
if ($isConnected) {
|
||||
ftp_close($connection);
|
||||
\App\Facades\FTP::close($connection);
|
||||
}
|
||||
|
||||
return $isConnected;
|
||||
@ -58,31 +58,36 @@ public function delete(array $paths): void
|
||||
|
||||
if ($connection && $this->login($connection)) {
|
||||
if ($this->storageProvider->credentials['passive']) {
|
||||
ftp_pasv($connection, true);
|
||||
\App\Facades\FTP::passive($connection, true);
|
||||
}
|
||||
|
||||
foreach ($paths as $path) {
|
||||
ftp_delete($connection, $this->storageProvider->credentials['path'].'/'.$path);
|
||||
\App\Facades\FTP::delete($connection, $this->storageProvider->credentials['path'].'/'.$path);
|
||||
}
|
||||
}
|
||||
|
||||
ftp_close($connection);
|
||||
\App\Facades\FTP::close($connection);
|
||||
}
|
||||
|
||||
private function connection(): bool|Connection
|
||||
{
|
||||
$credentials = $this->storageProvider->credentials;
|
||||
if ($credentials['ssl']) {
|
||||
return ftp_ssl_connect($credentials['host'], $credentials['port'], 5);
|
||||
}
|
||||
|
||||
return ftp_connect($credentials['host'], $credentials['port'], 5);
|
||||
return \App\Facades\FTP::connect(
|
||||
$credentials['host'],
|
||||
$credentials['port'],
|
||||
$credentials['ssl']
|
||||
);
|
||||
}
|
||||
|
||||
private function login(Connection $connection): bool
|
||||
private function login(bool|Connection $connection): bool
|
||||
{
|
||||
$credentials = $this->storageProvider->credentials;
|
||||
|
||||
return ftp_login($connection, $credentials['username'], $credentials['password']);
|
||||
return \App\Facades\FTP::login(
|
||||
$credentials['username'],
|
||||
$credentials['password'],
|
||||
$connection
|
||||
);
|
||||
}
|
||||
}
|
||||
|
60
app/Support/Testing/FTPFake.php
Normal file
60
app/Support/Testing/FTPFake.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Testing;
|
||||
|
||||
use FTP\Connection;
|
||||
use PHPUnit\Framework\Assert;
|
||||
|
||||
class FTPFake
|
||||
{
|
||||
protected array $connections = [];
|
||||
|
||||
protected array $logins = [];
|
||||
|
||||
public function connect(string $host, string $port, bool $ssl = false): bool|Connection
|
||||
{
|
||||
$this->connections[] = compact('host', 'port', 'ssl');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function login(string $username, string $password, bool|Connection $connection): bool
|
||||
{
|
||||
$this->logins[] = compact('username', 'password');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function close(bool|Connection $connection): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function passive(bool|Connection $connection, bool $passive): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function delete(bool|Connection $connection, string $path): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function assertConnected(string $host): void
|
||||
{
|
||||
if (! $this->connections) {
|
||||
Assert::fail('No connections are made');
|
||||
}
|
||||
$connected = false;
|
||||
foreach ($this->connections as $connection) {
|
||||
if ($connection['host'] === $host) {
|
||||
$connected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (! $connected) {
|
||||
Assert::fail('The expected host is not connected');
|
||||
}
|
||||
Assert::assertTrue(true, $connected);
|
||||
}
|
||||
}
|
47
app/View/Components/Editor.php
Normal file
47
app/View/Components/Editor.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\Component;
|
||||
|
||||
class Editor extends Component
|
||||
{
|
||||
public string $id;
|
||||
|
||||
public string $name;
|
||||
|
||||
public ?string $value;
|
||||
|
||||
public array $options;
|
||||
|
||||
public function __construct(
|
||||
string $name,
|
||||
?string $value,
|
||||
public string $lang,
|
||||
public bool $readonly = false,
|
||||
public bool $lineNumbers = true,
|
||||
) {
|
||||
$this->id = $name.'-'.Str::random(8);
|
||||
$this->name = $name;
|
||||
$this->value = json_encode($value ?? '');
|
||||
$this->options = $this->getOptions();
|
||||
}
|
||||
|
||||
private function getOptions(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'lang' => $this->lang,
|
||||
'value' => $this->value,
|
||||
];
|
||||
}
|
||||
|
||||
public function render(): View|Closure|string
|
||||
{
|
||||
return view('components.editor');
|
||||
}
|
||||
}
|
@ -430,7 +430,7 @@
|
||||
],
|
||||
'storage_providers_class' => [
|
||||
\App\Enums\StorageProvider::DROPBOX => \App\StorageProviders\Dropbox::class,
|
||||
\App\Enums\StorageProvider::FTP => \App\StorageProviders\Ftp::class,
|
||||
\App\Enums\StorageProvider::FTP => \App\StorageProviders\FTP::class,
|
||||
\App\Enums\StorageProvider::LOCAL => \App\StorageProviders\Local::class,
|
||||
],
|
||||
|
||||
@ -445,4 +445,30 @@
|
||||
30,
|
||||
90,
|
||||
],
|
||||
|
||||
'tag_colors' => [
|
||||
'slate',
|
||||
'gray',
|
||||
'red',
|
||||
'orange',
|
||||
'amber',
|
||||
'yellow',
|
||||
'lime',
|
||||
'green',
|
||||
'emerald',
|
||||
'teal',
|
||||
'cyan',
|
||||
'sky',
|
||||
'blue',
|
||||
'indigo',
|
||||
'violet',
|
||||
'purple',
|
||||
'fuchsia',
|
||||
'pink',
|
||||
'rose',
|
||||
],
|
||||
'taggable_types' => [
|
||||
\App\Models\Server::class,
|
||||
\App\Models\Site::class,
|
||||
],
|
||||
];
|
||||
|
@ -618,8 +618,8 @@
|
||||
'images' => [
|
||||
'ubuntu_18' => '112929540',
|
||||
'ubuntu_20' => '112929454',
|
||||
'ubuntu_22' => '129211873',
|
||||
'ubuntu_24' => '155133621',
|
||||
'ubuntu_22' => '159651797',
|
||||
'ubuntu_24' => '160232537',
|
||||
],
|
||||
],
|
||||
'vultr' => [
|
||||
|
23
database/factories/TagFactory.php
Normal file
23
database/factories/TagFactory.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class TagFactory extends Factory
|
||||
{
|
||||
protected $model = Tag::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'project_id' => 1,
|
||||
'created_at' => Carbon::now(), //
|
||||
'updated_at' => Carbon::now(),
|
||||
'name' => $this->faker->randomElement(['production', 'staging', 'development']),
|
||||
'color' => $this->faker->randomElement(config('core.tag_colors')),
|
||||
];
|
||||
}
|
||||
}
|
24
database/migrations/2024_08_09_180021_create_tags_table.php
Normal file
24
database/migrations/2024_08_09_180021_create_tags_table.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tags', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('project_id');
|
||||
$table->string('name');
|
||||
$table->string('color');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('tags');
|
||||
}
|
||||
};
|
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('taggables', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tag_id');
|
||||
$table->unsignedBigInteger('taggable_id');
|
||||
$table->string('taggable_type');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('taggables');
|
||||
}
|
||||
};
|
@ -8,9 +8,12 @@
|
||||
use App\Models\Site;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Foundation\Testing\WithFaker;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
use WithFaker;
|
||||
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*/
|
||||
@ -20,6 +23,12 @@ public function run(): void
|
||||
'name' => 'Test User',
|
||||
'email' => 'user@example.com',
|
||||
]);
|
||||
|
||||
$this->createResources($user);
|
||||
}
|
||||
|
||||
private function createResources(User $user): void
|
||||
{
|
||||
$server = Server::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'project_id' => $user->currentProject->id,
|
||||
|
8
package-lock.json
generated
8
package-lock.json
generated
@ -4,13 +4,13 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "vito",
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.2",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"alpinejs": "^3.4.2",
|
||||
"apexcharts": "^3.44.2",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"brace": "^0.11.1",
|
||||
"flowbite": "^2.3.0",
|
||||
"flowbite-datepicker": "^1.2.6",
|
||||
"htmx.org": "^1.9.10",
|
||||
@ -685,6 +685,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/brace": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/brace/-/brace-0.11.1.tgz",
|
||||
"integrity": "sha512-Fc8Ne62jJlKHiG/ajlonC4Sd66Pq68fFwK4ihJGNZpGqboc324SQk+lRvMzpPRuJOmfrJefdG8/7JdWX4bzJ2Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
|
@ -10,8 +10,11 @@
|
||||
"@tailwindcss/forms": "^0.5.2",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"alpinejs": "^3.4.2",
|
||||
"apexcharts": "^3.44.2",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"brace": "^0.11.1",
|
||||
"flowbite": "^2.3.0",
|
||||
"flowbite-datepicker": "^1.2.6",
|
||||
"htmx.org": "^1.9.10",
|
||||
"laravel-echo": "^1.15.0",
|
||||
"laravel-vite-plugin": "^0.7.2",
|
||||
@ -21,8 +24,6 @@
|
||||
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||
"tailwindcss": "^3.1.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"vite": "^4.5.3",
|
||||
"apexcharts": "^3.44.2",
|
||||
"flowbite-datepicker": "^1.2.6"
|
||||
"vite": "^4.5.3"
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
public/build/assets/app-888ea5fa.css
Normal file
1
public/build/assets/app-888ea5fa.css
Normal file
File diff suppressed because one or more lines are too long
807
public/build/assets/app-d9a3bf01.js
Normal file
807
public/build/assets/app-d9a3bf01.js
Normal file
File diff suppressed because one or more lines are too long
@ -1,6 +1,6 @@
|
||||
{
|
||||
"resources/css/app.css": {
|
||||
"file": "assets/app-7f487305.css",
|
||||
"file": "assets/app-888ea5fa.css",
|
||||
"isEntry": true,
|
||||
"src": "resources/css/app.css"
|
||||
},
|
||||
@ -12,7 +12,7 @@
|
||||
"css": [
|
||||
"assets/app-a1ae07b3.css"
|
||||
],
|
||||
"file": "assets/app-01264060.js",
|
||||
"file": "assets/app-d9a3bf01.js",
|
||||
"isEntry": true,
|
||||
"src": "resources/js/app.js"
|
||||
}
|
||||
|
40
resources/js/ace-editor/ace-editor.js
Normal file
40
resources/js/ace-editor/ace-editor.js
Normal file
@ -0,0 +1,40 @@
|
||||
import ace from 'brace';
|
||||
import 'brace/mode/javascript';
|
||||
import 'brace/mode/plain_text';
|
||||
import 'brace/mode/sh';
|
||||
import 'brace/mode/ini';
|
||||
import 'brace/ext/searchbox'
|
||||
import './theme-vito'
|
||||
import './mode-env';
|
||||
import './mode-nginx';
|
||||
|
||||
window.initAceEditor = function (options = {}) {
|
||||
const editorValue = JSON.parse(options.value || '');
|
||||
const editor = ace.edit(options.id);
|
||||
editor.setTheme("ace/theme/vito");
|
||||
editor.getSession().setMode(`ace/mode/${options.lang || 'plain_text'}`);
|
||||
editor.setValue(editorValue, -1);
|
||||
editor.clearSelection();
|
||||
editor.focus();
|
||||
editor.setOptions({
|
||||
enableBasicAutocompletion: true,
|
||||
enableSnippets: true,
|
||||
enableLiveAutocompletion: true,
|
||||
printMargin: false,
|
||||
});
|
||||
|
||||
editor.renderer.setScrollMargin(15, 15, 0, 0)
|
||||
editor.renderer.setPadding(15);
|
||||
|
||||
editor.getSession().on('change', function () {
|
||||
document.getElementById(`textarea-${options.id}`).value = editor.getValue();
|
||||
});
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
editor.resize();
|
||||
})
|
||||
|
||||
document.getElementById(`textarea-${options.id}`).innerHTML = editorValue;
|
||||
|
||||
return editor;
|
||||
}
|
136
resources/js/ace-editor/mode-env.js
Normal file
136
resources/js/ace-editor/mode-env.js
Normal file
@ -0,0 +1,136 @@
|
||||
ace.define("ace/mode/env", ["require", "exports", "module", "ace/lib/oop", "ace/mode/text", "ace/mode/env_highlight_rules", "ace/mode/folding/ini","ace/mode/behaviour"], function (require, exports) {
|
||||
var oop = require("../lib/oop");
|
||||
var TextMode = require("./text").Mode;
|
||||
var Behaviour = require("./behaviour").Behaviour;
|
||||
var envHighlightRules = require("./env_highlight_rules").envHighlightRules;
|
||||
|
||||
var Mode = function () {
|
||||
this.HighlightRules = envHighlightRules;
|
||||
this.$behaviour = new Behaviour
|
||||
};
|
||||
|
||||
oop.inherits(Mode, TextMode);
|
||||
|
||||
(function() {
|
||||
this.lineCommentStart = "#",
|
||||
this.blockComment = null,
|
||||
this.$id = "ace/mode/env"
|
||||
}).call(Mode.prototype),
|
||||
|
||||
exports.Mode = Mode;
|
||||
})
|
||||
ace.define("ace/mode/env_highlight_rules", ["require", "exports", "module", "ace/lib/oop", "ace/mode/text_highlight_rules"], function (require, exports, module) {
|
||||
"use strict";
|
||||
|
||||
var oop = require("../lib/oop");
|
||||
var TextHighlightRules =
|
||||
require("./text_highlight_rules").TextHighlightRules;
|
||||
|
||||
var envHighlightRules = function () {
|
||||
this.$rules = {
|
||||
start: [
|
||||
{
|
||||
token: "punctuation.definition.comment.env",
|
||||
regex: "#.*",
|
||||
push_: [
|
||||
{
|
||||
token: "comment.line.number-sign.env",
|
||||
regex: "$|^",
|
||||
next: "pop",
|
||||
},
|
||||
{
|
||||
defaultToken: "comment.line.number-sign.env",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
token: "punctuation.definition.comment.env",
|
||||
regex: "#.*",
|
||||
push_: [
|
||||
{
|
||||
token: "comment.line.semicolon.env",
|
||||
regex: "$|^",
|
||||
next: "pop",
|
||||
},
|
||||
{
|
||||
defaultToken: "comment.line.semicolon.env",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
token: [
|
||||
"keyword.other.definition.env",
|
||||
"text",
|
||||
"punctuation.separator.key-value.env",
|
||||
],
|
||||
regex: "\\b([a-zA-Z0-9_.-]+)\\b(\\s*)(=)",
|
||||
},
|
||||
{
|
||||
token: [
|
||||
"punctuation.definition.entity.env",
|
||||
"constant.section.group-title.env",
|
||||
"punctuation.definition.entity.env",
|
||||
],
|
||||
regex: "^(\\[)(.*?)(\\])",
|
||||
},
|
||||
{
|
||||
token: "punctuation.definition.string.begin.env",
|
||||
regex: "'",
|
||||
push: [
|
||||
{
|
||||
token: "punctuation.definition.string.end.env",
|
||||
regex: "'",
|
||||
next: "pop",
|
||||
},
|
||||
{
|
||||
token: "constant.language.escape",
|
||||
regex: "\\\\(?:[\\\\0abtrn;#=:]|x[a-fA-F\\d]{4})",
|
||||
},
|
||||
{
|
||||
defaultToken: "string.quoted.single.env",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
token: "punctuation.definition.string.begin.env",
|
||||
regex: '"',
|
||||
push: [
|
||||
{
|
||||
token: "constant.language.escape",
|
||||
regex: "\\\\(?:[\\\\0abtrn;#=:]|x[a-fA-F\\d]{4})",
|
||||
},
|
||||
{
|
||||
token: "support.constant.color",
|
||||
regex: /\${[\w]+}/,
|
||||
},
|
||||
{
|
||||
token: "punctuation.definition.string.end.env",
|
||||
regex: '"',
|
||||
next: "pop",
|
||||
},
|
||||
{
|
||||
defaultToken: "string.quoted.double.env",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
token: "constant.language.boolean",
|
||||
regex: /(?:true|false)\b/,
|
||||
},
|
||||
],
|
||||
};
|
||||
this.normalizeRules();
|
||||
};
|
||||
|
||||
envHighlightRules.metaData = {
|
||||
fileTypes: ["env"],
|
||||
keyEquivalent: "^~I",
|
||||
name: "Env",
|
||||
scopeName: "source.env",
|
||||
};
|
||||
|
||||
oop.inherits(envHighlightRules, TextHighlightRules);
|
||||
|
||||
exports.envHighlightRules = envHighlightRules;
|
||||
});
|
||||
|
143
resources/js/ace-editor/mode-nginx.js
Normal file
143
resources/js/ace-editor/mode-nginx.js
Normal file
File diff suppressed because one or more lines are too long
47
resources/js/ace-editor/theme-vito.js
Normal file
47
resources/js/ace-editor/theme-vito.js
Normal file
@ -0,0 +1,47 @@
|
||||
ace.define(
|
||||
"ace/theme/vito",
|
||||
["require", "exports", "module", "ace/lib/dom"],
|
||||
function (require, exports) {
|
||||
(exports.isDark = true),
|
||||
(exports.cssClass = "ace-vito rounded-lg w-full"),
|
||||
(exports.cssText = `
|
||||
.ace-vito .ace_scrollbar::-webkit-scrollbar { width: 12px;}
|
||||
.ace-vito .ace_scrollbar::-webkit-scrollbar-track { background: #111827;}
|
||||
.ace-vito .ace_scrollbar::-webkit-scrollbar-thumb { background: #374151; border-radius: 4px;}
|
||||
.ace-vito .ace_gutter {background: #151c27;color: rgb(128,145,160)}
|
||||
.ace-vito .ace_print-margin {width: 1px;background: #555555}
|
||||
.ace-vito {background-color: #0f172a;color: #F9FAFB}
|
||||
.ace-vito .ace_cursor {color: #F9FAFB}
|
||||
.ace-vito .ace_marker-layer .ace_selection {background: rgba(179, 101, 57, 0.75)}
|
||||
.ace-vito.ace_multiselect .ace_selection.ace_start {box-shadow: 0 0 3px 0px #002240;}
|
||||
.ace-vito .ace_marker-layer .ace_step {background: rgb(127, 111, 19)}
|
||||
.ace-vito .ace_marker-layer .ace_bracket {margin: -1px 0 0 -1px;border: 1px solid rgba(255, 255, 255, 0.15)}
|
||||
.ace-vito .ace_marker-layer .ace_active-line {background: rgba(24, 182, 155, 0.10)}
|
||||
.ace-vito .ace_gutter-active-line {background-color: rgba(0, 0, 0, 0.35)}
|
||||
.ace-vito .ace_marker-layer .ace_selected-word {border: 1px solid rgba(179, 101, 57, 0.75)}
|
||||
.ace-vito .ace_invisible {color: rgba(255, 255, 255, 0.15)}
|
||||
.ace-vito .ace_keyword,.ace-vito .ace_meta {color: #FF9D00}
|
||||
.ace-vito .ace_constant,.ace-vito .ace_constant.ace_character,.ace-vito .ace_constant.ace_character.ace_escape,.ace-vito .ace_constant.ace_other {color: #FF628C}
|
||||
.ace-vito .ace_invalid {color: #F8F8F8;background-color: #800F00}
|
||||
.ace-vito .ace_support {color: #80FFBB}
|
||||
.ace-vito .ace_support.ace_constant {color: #EB939A}
|
||||
.ace-vito .ace_fold {background-color: #FF9D00;border-color: #F9FAFB}
|
||||
.ace-vito .ace_support.ace_function {color: #FFB054}
|
||||
.ace-vito .ace_storage {color: #FFEE80}
|
||||
.ace-vito .ace_entity {color: #FFDD00}
|
||||
.ace-vito .ace_string {color: #7cd827}
|
||||
.ace-vito .ace_string.ace_regexp {color: #80FFC2}
|
||||
.ace-vito .ace_comment {font-style: italic;color: #6B7280}
|
||||
.ace-vito .ace_heading,.ace-vito
|
||||
.ace_markup.ace_heading {color: #C8E4FD;background-color: #001221}
|
||||
.ace-vito .ace_list,.ace-vito .ace_markup.ace_list {background-color: #130D26}
|
||||
.ace-vito .ace_variable {color: #CCCCCC}
|
||||
.ace-vito .ace_variable.ace_language {color: #FF80E1}
|
||||
.ace-vito .ace_meta.ace_tag {color: #9EFFFF}
|
||||
.ace-vito .ace_indent-guide {background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQImWNgYGBgYHCLSvkPAAP3AgSDTRd4AAAAAElFTkSuQmCC) right repeat-y}
|
||||
`);
|
||||
|
||||
var dom = require("../lib/dom");
|
||||
dom.importCssString(exports.cssText, exports.cssClass);
|
||||
},
|
||||
);
|
@ -1,5 +1,6 @@
|
||||
import 'flowbite';
|
||||
import 'flowbite/dist/datepicker.js';
|
||||
import './ace-editor/ace-editor';
|
||||
|
||||
import Alpine from 'alpinejs';
|
||||
window.Alpine = Alpine;
|
||||
|
@ -16,9 +16,8 @@ class="p-6"
|
||||
|
||||
<div class="mt-6">
|
||||
<x-input-label for="script" :value="__('Script')" />
|
||||
<x-textarea id="script" name="script" class="mt-1 min-h-[400px] w-full font-mono">
|
||||
{{ old("script", $site->deploymentScript?->content) }}
|
||||
</x-textarea>
|
||||
@php($value = old("script", $site->deploymentScript?->content))
|
||||
<x-editor id="script" name="script" lang="sh" :value="$value" />
|
||||
@error("script")
|
||||
<x-input-error class="mt-2" :messages="$message" />
|
||||
@enderror
|
||||
|
@ -21,9 +21,8 @@ class="mt-6"
|
||||
>
|
||||
<x-input-label for="env" :value="__('.env')" />
|
||||
<div id="env-content">
|
||||
<x-textarea id="env" name="env" rows="10" class="mt-1 block min-h-[400px] w-full font-mono">
|
||||
{{ old("env", session()->get("env") ?? "Loading...") }}
|
||||
</x-textarea>
|
||||
@php($envValue = old("env", session()->get("env") ?? "Loading..."))
|
||||
<x-editor id="env" name="env" lang="env" :value="$envValue" />
|
||||
</div>
|
||||
@error("env")
|
||||
<x-input-error class="mt-2" :messages="$message" />
|
||||
|
64
resources/views/components/autocomplete-text.blade.php
Normal file
64
resources/views/components/autocomplete-text.blade.php
Normal file
@ -0,0 +1,64 @@
|
||||
@props([
|
||||
"id",
|
||||
"name",
|
||||
"placeholder" => "Search...",
|
||||
"items" => [],
|
||||
"maxResults" => 5,
|
||||
"value" => "",
|
||||
])
|
||||
|
||||
<script>
|
||||
window['items_' + @js($id)] = @json($items);
|
||||
</script>
|
||||
|
||||
<div
|
||||
x-data="{
|
||||
q: @js($value),
|
||||
items: window['items_' + @js($id)],
|
||||
resultItems: window['items_' + @js($id)],
|
||||
maxResults: @js($maxResults),
|
||||
init() {
|
||||
this.search()
|
||||
},
|
||||
search() {
|
||||
if (! this.q) {
|
||||
this.resultItems = this.items.slice(0, this.maxResults)
|
||||
return
|
||||
}
|
||||
this.resultItems = this.items
|
||||
.filter((item) => item.toLowerCase().includes(this.q.toLowerCase()))
|
||||
.slice(0, this.maxResults)
|
||||
},
|
||||
}"
|
||||
>
|
||||
<input type="hidden" name="{{ $name }}" x-ref="input" x-model="q" />
|
||||
<x-dropdown width="full" :hide-if-empty="true">
|
||||
<x-slot name="trigger">
|
||||
<x-text-input
|
||||
id="$id . '-q"
|
||||
x-model="q"
|
||||
type="text"
|
||||
class="mt-1 w-full"
|
||||
:placeholder="$placeholder"
|
||||
autocomplete="off"
|
||||
x-on:input.debounce.100ms="search"
|
||||
/>
|
||||
</x-slot>
|
||||
<x-slot name="content">
|
||||
<div
|
||||
id="{{ $id }}-items-list"
|
||||
x-bind:class="
|
||||
resultItems.length > 0
|
||||
? 'py-1 border border-gray-200 dark:border-gray-600 rounded-md'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<template x-for="item in resultItems">
|
||||
<x-dropdown-link class="cursor-pointer" x-on:click="q = item">
|
||||
<span x-text="item"></span>
|
||||
</x-dropdown-link>
|
||||
</template>
|
||||
</div>
|
||||
</x-slot>
|
||||
</x-dropdown>
|
||||
</div>
|
10
resources/views/components/dropdown-trigger.blade.php
Normal file
10
resources/views/components/dropdown-trigger.blade.php
Normal file
@ -0,0 +1,10 @@
|
||||
<div>
|
||||
<div
|
||||
class="block w-full cursor-pointer rounded-md border border-gray-300 p-2.5 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-primary-600 dark:focus:ring-primary-600"
|
||||
>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
<button type="button" class="absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<x-heroicon name="o-chevron-down" class="h-4 w-4 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
@ -2,12 +2,18 @@
|
||||
"open" => false,
|
||||
"align" => "right",
|
||||
"width" => "48",
|
||||
"contentClasses" => "list-none divide-y divide-gray-100 rounded-md border border-gray-200 bg-white py-1 text-base dark:divide-gray-600 dark:border-gray-600 dark:bg-gray-700",
|
||||
"contentClasses" => "list-none divide-y divide-gray-100 rounded-md bg-white text-base dark:divide-gray-600 dark:bg-gray-700",
|
||||
"search" => false,
|
||||
"searchUrl" => "",
|
||||
"hideIfEmpty" => false,
|
||||
"closeOnClick" => true,
|
||||
])
|
||||
|
||||
@php
|
||||
if (! $hideIfEmpty) {
|
||||
$contentClasses .= " py-1 border border-gray-200 dark:border-gray-600";
|
||||
}
|
||||
|
||||
switch ($align) {
|
||||
case "left":
|
||||
$alignmentClasses = "left-0 origin-top-left";
|
||||
@ -25,6 +31,9 @@
|
||||
case "48":
|
||||
$width = "w-48";
|
||||
break;
|
||||
case "56":
|
||||
$width = "w-56";
|
||||
break;
|
||||
case "full":
|
||||
$width = "w-full";
|
||||
break;
|
||||
@ -46,31 +55,9 @@
|
||||
x-transition:leave-end="scale-95 transform opacity-0"
|
||||
class="{{ $width }} {{ $alignmentClasses }} absolute z-50 mt-2 rounded-md"
|
||||
style="display: none"
|
||||
@click="open = false"
|
||||
@if ($closeOnClick) @click="open = false" @endif
|
||||
>
|
||||
<div class="{{ $contentClasses }} rounded-md">
|
||||
@if ($search)
|
||||
<div class="p-2">
|
||||
<input
|
||||
type="text"
|
||||
x-ref="search"
|
||||
x-model="search"
|
||||
x-on:keydown.window.prevent.enter="open = false"
|
||||
x-on:keydown.window.prevent.escape="open = false"
|
||||
x-on:keydown.window.prevent.arrow-up="
|
||||
open = true
|
||||
$refs.search.focus()
|
||||
"
|
||||
x-on:keydown.window.prevent.arrow-down="
|
||||
open = true
|
||||
$refs.search.focus()
|
||||
"
|
||||
class="w-full rounded-md border border-gray-200 p-2"
|
||||
placeholder="Search..."
|
||||
/>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{ $content }}
|
||||
</div>
|
||||
</div>
|
||||
|
17
resources/views/components/editor.blade.php
Normal file
17
resources/views/components/editor.blade.php
Normal file
@ -0,0 +1,17 @@
|
||||
<div>
|
||||
<div
|
||||
id="{{ $id }}"
|
||||
{{ $attributes->merge(["class" => "mt-1 min-h-[400px] w-full"]) }}
|
||||
class="ace-vito ace_dark"
|
||||
></div>
|
||||
<textarea id="textarea-{{ $id }}" name="{{ $name }}" style="display: none"></textarea>
|
||||
<script>
|
||||
if (window.initAceEditor) {
|
||||
window.initAceEditor(@json($options));
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
window.initAceEditor(@json($options));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</div>
|
14
resources/views/components/heroicons/o-funnel.blade.php
Normal file
14
resources/views/components/heroicons/o-funnel.blade.php
Normal file
@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
{{ $attributes }}
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 556 B |
10
resources/views/components/heroicons/o-plus.blade.php
Normal file
10
resources/views/components/heroicons/o-plus.blade.php
Normal file
@ -0,0 +1,10 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
{{ $attributes }}
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
After Width: | Height: | Size: 251 B |
15
resources/views/components/heroicons/o-tag.blade.php
Normal file
15
resources/views/components/heroicons/o-tag.blade.php
Normal file
@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
{{ $attributes }}
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6Z" />
|
||||
</svg>
|
After Width: | Height: | Size: 546 B |
@ -73,7 +73,7 @@ class="fixed inset-0 transform transition-all"
|
||||
|
||||
<div
|
||||
x-show="show"
|
||||
class="{{ $maxWidth }} mb-6 transform overflow-hidden rounded-lg bg-white shadow-xl transition-all dark:bg-gray-800 sm:mx-auto sm:w-full"
|
||||
class="{{ $maxWidth }} mb-6 transform overflow-visible rounded-lg bg-white shadow-xl transition-all dark:bg-gray-800 sm:mx-auto sm:w-full"
|
||||
x-transition:enter="duration-300 ease-out"
|
||||
x-transition:enter-start="translate-y-4 opacity-0 sm:translate-y-0 sm:scale-95"
|
||||
x-transition:enter-end="translate-y-0 opacity-100 sm:scale-100"
|
||||
|
@ -71,5 +71,6 @@
|
||||
</script>
|
||||
<x-toast />
|
||||
<x-htmx-error-handler />
|
||||
@stack("footer")
|
||||
</body>
|
||||
</html>
|
||||
|
@ -11,7 +11,12 @@
|
||||
</div>
|
||||
</header>
|
||||
@else
|
||||
<h2 class="text-lg font-semibold">{{ $server->name }}</h2>
|
||||
<div class="flex items-center">
|
||||
<h2 class="text-lg font-semibold">{{ $server->name }}</h2>
|
||||
<div class="ml-2">
|
||||
@include("settings.tags.tags", ["taggable" => $server])
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-col items-end">
|
||||
|
@ -161,6 +161,13 @@ class="fixed left-0 top-0 z-40 h-screen w-64 -translate-x-full border-r border-g
|
||||
<x-hr />
|
||||
@endif
|
||||
|
||||
<li>
|
||||
<x-sidebar-link :href="route('servers')" :active="request()->routeIs('servers')">
|
||||
<x-heroicon name="o-server" class="h-6 w-6" />
|
||||
<span class="ml-2">Servers</span>
|
||||
</x-sidebar-link>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<x-sidebar-link :href="route('scripts.index')" :active="request()->routeIs('scripts.*')">
|
||||
<x-heroicon name="o-bolt" class="h-6 w-6" />
|
||||
@ -239,6 +246,12 @@ class="fixed left-0 top-0 z-40 h-screen w-64 -translate-x-full border-r border-g
|
||||
<span class="ml-2">SSH Keys</span>
|
||||
</x-sidebar-link>
|
||||
</li>
|
||||
<li>
|
||||
<x-sidebar-link :href="route('settings.tags')" :active="request()->routeIs('settings.tags')">
|
||||
<x-heroicon name="o-tag" class="h-6 w-6" />
|
||||
<span class="ml-2">Tags</span>
|
||||
</x-sidebar-link>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -40,7 +40,7 @@ class="cursor-pointer"
|
||||
@foreach ([\App\Enums\PHPIniType::FPM, \App\Enums\PHPIniType::CLI] as $type)
|
||||
<x-dropdown-link
|
||||
class="cursor-pointer"
|
||||
x-on:click="version = '{{ $php->version }}'; $dispatch('open-modal', 'update-php-ini-{{ $type }}'); document.getElementById('ini').value = 'Loading...';"
|
||||
x-on:click="version = '{{ $php->version }}'; $dispatch('open-modal', 'update-php-ini-{{ $type }}');"
|
||||
hx-get="{{ route('servers.php.get-ini', ['server' => $server, 'version' => $php->version, 'type' => $type]) }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#update-php-ini-{{ $type }}-form"
|
||||
|
@ -15,9 +15,8 @@ class="p-6"
|
||||
|
||||
<div class="mt-6">
|
||||
<x-input-label for="ini" value="php.ini" />
|
||||
<x-textarea id="ini" name="ini" class="mt-1 w-full font-mono" rows="15">
|
||||
{{ old("ini", session()->get("ini")) }}
|
||||
</x-textarea>
|
||||
@php($ini = old("ini", session()->get("ini") ?? "Loading..."))
|
||||
<x-editor id="ini" name="ini" lang="ini" :value="$ini" />
|
||||
@error("ini")
|
||||
<x-input-error class="mt-2" :messages="$message" />
|
||||
@enderror
|
||||
|
@ -1,7 +1,5 @@
|
||||
<x-input-label for="content" :value="__('Content')" />
|
||||
<x-textarea id="content" name="content" class="mt-1 min-h-[400px] w-full font-mono">
|
||||
{{ $value }}
|
||||
</x-textarea>
|
||||
<x-editor name="content" lang="sh" :value="$value" />
|
||||
@error("content")
|
||||
<x-input-error class="mt-2" :messages="$message" />
|
||||
@enderror
|
||||
|
@ -110,4 +110,15 @@ class="mt-2 md:ml-2 md:mt-0"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="py-5">
|
||||
<div class="border-t border-gray-200 dark:border-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>{{ __("Tags") }}</div>
|
||||
<div>
|
||||
@include("settings.tags.tags", ["taggable" => $server, "edit" => true])
|
||||
</div>
|
||||
</div>
|
||||
</x-card>
|
||||
|
@ -9,40 +9,73 @@
|
||||
</x-slot>
|
||||
</x-card-header>
|
||||
|
||||
<x-live id="live-servers-list">
|
||||
@if (count($servers) > 0)
|
||||
<div class="space-y-3">
|
||||
@foreach ($servers as $server)
|
||||
<a href="{{ route("servers.show", ["server" => $server]) }}" class="block">
|
||||
<x-item-card>
|
||||
<div class="flex-none">
|
||||
<img
|
||||
src="{{ asset("static/images/" . $server->provider . ".svg") }}"
|
||||
class="h-10 w-10"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-3 flex flex-grow flex-col items-start justify-center">
|
||||
<span class="mb-1">{{ $server->name }}</span>
|
||||
<span class="text-sm text-gray-400">
|
||||
{{ $server->ip }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="inline">
|
||||
@include("servers.partials.server-status", ["server" => $server])
|
||||
</div>
|
||||
</div>
|
||||
</x-item-card>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<x-simple-card>
|
||||
<div class="text-center">
|
||||
{{ __("You don't have any servers yet!") }}
|
||||
<div class="space-y-3">
|
||||
<x-live id="live-servers-list">
|
||||
@if (count($servers) > 0)
|
||||
<div class="space-y-3">
|
||||
<x-table>
|
||||
<x-thead>
|
||||
<x-tr>
|
||||
<x-th>Name</x-th>
|
||||
<x-th>IP</x-th>
|
||||
<x-th>Tags</x-th>
|
||||
<x-th>Status</x-th>
|
||||
<x-th></x-th>
|
||||
</x-tr>
|
||||
</x-thead>
|
||||
<x-tbody>
|
||||
@foreach ($servers as $server)
|
||||
<x-tr>
|
||||
<x-td>
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
src="{{ asset("static/images/" . $server->provider . ".svg") }}"
|
||||
class="mr-1 h-5 w-5"
|
||||
alt=""
|
||||
/>
|
||||
<a
|
||||
href="{{ route("servers.show", ["server" => $server]) }}"
|
||||
class="hover:underline"
|
||||
>
|
||||
{{ $server->name }}
|
||||
</a>
|
||||
</div>
|
||||
</x-td>
|
||||
<x-td>{{ $server->ip }}</x-td>
|
||||
<x-td>
|
||||
@include("settings.tags.tags", ["taggable" => $server, "oobOff" => true])
|
||||
</x-td>
|
||||
<x-td>
|
||||
@include("servers.partials.server-status", ["server" => $server])
|
||||
</x-td>
|
||||
<x-td>
|
||||
<div class="flex items-center justify-end">
|
||||
<x-icon-button
|
||||
:href="route('servers.show', ['server' => $server])"
|
||||
data-tooltip="Show Server"
|
||||
>
|
||||
<x-heroicon name="o-eye" class="h-5 w-5" />
|
||||
</x-icon-button>
|
||||
<x-icon-button
|
||||
:href="route('servers.settings', ['server' => $server])"
|
||||
data-tooltip="Settings"
|
||||
>
|
||||
<x-heroicon name="o-wrench-screwdriver" class="h-5 w-5" />
|
||||
</x-icon-button>
|
||||
</div>
|
||||
</x-td>
|
||||
</x-tr>
|
||||
@endforeach
|
||||
</x-tbody>
|
||||
</x-table>
|
||||
</div>
|
||||
</x-simple-card>
|
||||
@endif
|
||||
</x-live>
|
||||
@else
|
||||
<x-simple-card>
|
||||
<div class="text-center">
|
||||
{{ __("You don't have any servers yet!") }}
|
||||
</div>
|
||||
</x-simple-card>
|
||||
@endif
|
||||
</x-live>
|
||||
</div>
|
||||
</x-container>
|
||||
|
41
resources/views/settings/tags/attach.blade.php
Normal file
41
resources/views/settings/tags/attach.blade.php
Normal file
@ -0,0 +1,41 @@
|
||||
<div x-data="">
|
||||
<button type="button" class="flex items-center" x-on:click="$dispatch('open-modal', 'create-tag-modal')">
|
||||
<x-heroicon name="o-plus" class="h-5 w-5 text-gray-500 dark:text-gray-400" />
|
||||
<span class="text-md">New Tag</span>
|
||||
</button>
|
||||
@push("footer")
|
||||
<x-modal name="create-tag-modal" max-width="sm">
|
||||
<form
|
||||
id="create-tag-form"
|
||||
hx-post="{{ route("tags.attach") }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-select="#create-tag-form"
|
||||
class="p-6"
|
||||
>
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">New Tag</h2>
|
||||
|
||||
<input type="hidden" name="taggable_type" value="{{ get_class($taggable) }}" />
|
||||
<input type="hidden" name="taggable_id" value="{{ $taggable->id }}" />
|
||||
|
||||
<div class="mt-6">
|
||||
@include("settings.tags.fields.name",["value" => old("name"),"items" => auth()->user()->currentProject->tags()->pluck("name"),])
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<x-secondary-button type="button" x-on:click="$dispatch('close')">Cancel</x-secondary-button>
|
||||
<x-primary-button class="ml-3" hx-disable>Save</x-primary-button>
|
||||
</div>
|
||||
|
||||
@if (session()->has("status") && session()->get("status") === "tag-created")
|
||||
<script defer>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('close-modal', {
|
||||
detail: 'create-tag-modal'
|
||||
})
|
||||
);
|
||||
</script>
|
||||
@endif
|
||||
</form>
|
||||
</x-modal>
|
||||
@endpush
|
||||
</div>
|
31
resources/views/settings/tags/fields/color.blade.php
Normal file
31
resources/views/settings/tags/fields/color.blade.php
Normal file
@ -0,0 +1,31 @@
|
||||
@php($id = "color-" . uniqid())
|
||||
<div x-data="{
|
||||
value: '{{ $value }}',
|
||||
}">
|
||||
<x-input-label for="color" :value="__('Color')" />
|
||||
<input x-bind:value="value" id="{{ $id }}" name="color" type="hidden" />
|
||||
<x-dropdown class="relative" align="left">
|
||||
<x-slot name="trigger">
|
||||
<x-dropdown-trigger>
|
||||
<div class="flex items-center">
|
||||
<div x-show="value" x-bind:class="`bg-${value}-500 mr-1 h-3 w-3 rounded-full`"></div>
|
||||
<span x-text="value || 'Select a color'"></span>
|
||||
</div>
|
||||
</x-dropdown-trigger>
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="content">
|
||||
<div class="z-50 max-h-[200px] overflow-y-auto">
|
||||
@foreach (config("core.tag_colors") as $color)
|
||||
<x-dropdown-link href="#" x-on:click="value = '{{ $color }}'" class="flex items-center capitalize">
|
||||
<div class="bg-{{ $color }}-500 mr-1 h-3 w-3 rounded-full"></div>
|
||||
{{ $color }}
|
||||
</x-dropdown-link>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-slot>
|
||||
</x-dropdown>
|
||||
@error("color")
|
||||
<x-input-error class="mt-2" :messages="$message" />
|
||||
@enderror
|
||||
</div>
|
12
resources/views/settings/tags/fields/name.blade.php
Normal file
12
resources/views/settings/tags/fields/name.blade.php
Normal file
@ -0,0 +1,12 @@
|
||||
@php
|
||||
$id = "name-" . uniqid();
|
||||
if (! isset($items)) {
|
||||
$items = [];
|
||||
}
|
||||
@endphp
|
||||
|
||||
<x-input-label :for="$id" :value="__('Name')" />
|
||||
<x-autocomplete-text id="tag-name" name="name" :items="$items" :value="$value" placeholder="" />
|
||||
@error("name")
|
||||
<x-input-error class="mt-2" :messages="$message" />
|
||||
@enderror
|
5
resources/views/settings/tags/index.blade.php
Normal file
5
resources/views/settings/tags/index.blade.php
Normal file
@ -0,0 +1,5 @@
|
||||
<x-settings-layout>
|
||||
<x-slot name="pageTitle">{{ __("Tags") }}</x-slot>
|
||||
|
||||
@include("settings.tags.partials.tags-list")
|
||||
</x-settings-layout>
|
42
resources/views/settings/tags/manage.blade.php
Normal file
42
resources/views/settings/tags/manage.blade.php
Normal file
@ -0,0 +1,42 @@
|
||||
<x-modal name="manage-tags-modal">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">Manage Tags</h2>
|
||||
@include("settings.tags.attach", ["taggable" => $taggable])
|
||||
</div>
|
||||
|
||||
<x-table id="tags-{{ $taggable->id }}" class="mt-6" hx-swap-oob="outerHTML">
|
||||
<x-thead>
|
||||
<x-tr>
|
||||
<x-th>Name</x-th>
|
||||
<x-th></x-th>
|
||||
</x-tr>
|
||||
</x-thead>
|
||||
<x-tbody>
|
||||
@foreach ($taggable->tags as $tag)
|
||||
<x-tr>
|
||||
<x-td>
|
||||
<div class="flex items-center">
|
||||
<div class="bg-{{ $tag->color }}-500 mr-1 h-3 w-3 rounded-full"></div>
|
||||
{{ $tag->name }}
|
||||
</div>
|
||||
</x-td>
|
||||
<x-td class="text-right">
|
||||
<form
|
||||
id="detach-tag-{{ $tag->id }}"
|
||||
hx-post="{{ route("tags.detach", ["tag" => $tag]) }}"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<input type="hidden" name="taggable_type" value="{{ get_class($taggable) }}" />
|
||||
<input type="hidden" name="taggable_id" value="{{ $taggable->id }}" />
|
||||
<x-icon-button>
|
||||
<x-heroicon name="o-trash" class="h-5 w-5 text-gray-500 dark:text-gray-400" />
|
||||
</x-icon-button>
|
||||
</form>
|
||||
</x-td>
|
||||
</x-tr>
|
||||
@endforeach
|
||||
</x-tbody>
|
||||
</x-table>
|
||||
</div>
|
||||
</x-modal>
|
41
resources/views/settings/tags/partials/create-tag.blade.php
Normal file
41
resources/views/settings/tags/partials/create-tag.blade.php
Normal file
@ -0,0 +1,41 @@
|
||||
<div>
|
||||
<x-primary-button x-data="" x-on:click.prevent="$dispatch('open-modal', 'create-tag')">
|
||||
{{ __("Create Tag") }}
|
||||
</x-primary-button>
|
||||
|
||||
<x-modal name="create-tag">
|
||||
<form
|
||||
id="create-tag-form"
|
||||
hx-post="{{ route("settings.tags.create") }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-select="#create-tag-form"
|
||||
hx-ext="disable-element"
|
||||
hx-disable-element="#btn-create-tag"
|
||||
class="p-6"
|
||||
x-data="{}"
|
||||
>
|
||||
@csrf
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ __("Create Tag") }}
|
||||
</h2>
|
||||
|
||||
<div class="mt-6">
|
||||
@include("settings.tags.fields.name", ["value" => old("name")])
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
@include("settings.tags.fields.color", ["value" => old("color")])
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<x-secondary-button type="button" x-on:click="$dispatch('close')">
|
||||
{{ __("Cancel") }}
|
||||
</x-secondary-button>
|
||||
|
||||
<x-primary-button id="btn-create-tag" class="ml-3">
|
||||
{{ __("Save") }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
</x-modal>
|
||||
</div>
|
18
resources/views/settings/tags/partials/delete-tag.blade.php
Normal file
18
resources/views/settings/tags/partials/delete-tag.blade.php
Normal file
@ -0,0 +1,18 @@
|
||||
<x-modal name="delete-tag" :show="$errors->isNotEmpty()">
|
||||
<form id="delete-tag-form" method="post" x-bind:action="deleteAction" class="p-6">
|
||||
@csrf
|
||||
@method("delete")
|
||||
|
||||
<h2 class="text-lg font-medium">Deleting a tag will detach it from all the resources that it has been used</h2>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<x-secondary-button type="button" x-on:click="$dispatch('close')">
|
||||
{{ __("Cancel") }}
|
||||
</x-secondary-button>
|
||||
|
||||
<x-danger-button class="ml-3">
|
||||
{{ __("Delete") }}
|
||||
</x-danger-button>
|
||||
</div>
|
||||
</form>
|
||||
</x-modal>
|
39
resources/views/settings/tags/partials/edit-tag.blade.php
Normal file
39
resources/views/settings/tags/partials/edit-tag.blade.php
Normal file
@ -0,0 +1,39 @@
|
||||
<x-modal
|
||||
name="edit-tag"
|
||||
:show="true"
|
||||
x-on:modal-edit-tag-closed.window="window.history.pushState('', '', '{{ route('settings.tags') }}');"
|
||||
>
|
||||
<form
|
||||
id="edit-tag-form"
|
||||
hx-post="{{ route("settings.tags.update", ["tag" => $tag->id]) }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-select="#edit-tag-form"
|
||||
hx-ext="disable-element"
|
||||
hx-disable-element="#btn-edit-tag"
|
||||
class="p-6"
|
||||
>
|
||||
@csrf
|
||||
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ __("Edit Tag") }}
|
||||
</h2>
|
||||
|
||||
<div class="mt-6">
|
||||
@include("settings.tags.fields.name", ["value" => old("name", $tag->name)])
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
@include("settings.tags.fields.color", ["value" => old("color", $tag->color)])
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<x-secondary-button type="button" x-on:click="$dispatch('close')">
|
||||
{{ __("Cancel") }}
|
||||
</x-secondary-button>
|
||||
|
||||
<x-primary-button id="btn-edit-tag" class="ml-3">
|
||||
{{ __("Save") }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
</x-modal>
|
69
resources/views/settings/tags/partials/tags-list.blade.php
Normal file
69
resources/views/settings/tags/partials/tags-list.blade.php
Normal file
@ -0,0 +1,69 @@
|
||||
<div>
|
||||
<x-card-header>
|
||||
<x-slot name="title">{{ __("Tags") }}</x-slot>
|
||||
<x-slot name="description">
|
||||
{{ __("You can manage tags here") }}
|
||||
</x-slot>
|
||||
<x-slot name="aside">
|
||||
@include("settings.tags.partials.create-tag")
|
||||
</x-slot>
|
||||
</x-card-header>
|
||||
<div x-data="{ deleteAction: '' }" class="space-y-3">
|
||||
@if (count($tags) > 0)
|
||||
<x-table class="mt-6">
|
||||
<x-thead>
|
||||
<x-tr>
|
||||
<x-th>Name</x-th>
|
||||
<x-th></x-th>
|
||||
</x-tr>
|
||||
</x-thead>
|
||||
<x-tbody>
|
||||
@foreach ($tags as $tag)
|
||||
<x-tr>
|
||||
<x-td>
|
||||
<div class="flex items-center">
|
||||
<div class="bg-{{ $tag->color }}-500 mr-1 size-4 rounded-full"></div>
|
||||
{{ $tag->name }}
|
||||
</div>
|
||||
</x-td>
|
||||
<x-td class="text-right">
|
||||
<div class="inline">
|
||||
<x-icon-button
|
||||
id="edit-{{ $tag->id }}"
|
||||
hx-get="{{ route('settings.tags', ['edit' => $tag->id]) }}"
|
||||
hx-replace-url="true"
|
||||
hx-select="#edit"
|
||||
hx-target="#edit"
|
||||
hx-ext="disable-element"
|
||||
hx-disable-element="#edit-{{ $tag->id }}"
|
||||
>
|
||||
<x-heroicon name="o-pencil" class="h-5 w-5" />
|
||||
</x-icon-button>
|
||||
<x-icon-button
|
||||
x-on:click="deleteAction = '{{ route('settings.tags.delete', ['tag' => $tag]) }}'; $dispatch('open-modal', 'delete-tag')"
|
||||
>
|
||||
<x-heroicon name="o-trash" class="h-5 w-5" />
|
||||
</x-icon-button>
|
||||
</div>
|
||||
</x-td>
|
||||
</x-tr>
|
||||
@endforeach
|
||||
</x-tbody>
|
||||
</x-table>
|
||||
|
||||
@include("settings.tags.partials.delete-tag")
|
||||
|
||||
<div id="edit">
|
||||
@if (isset($editTag))
|
||||
@include("settings.tags.partials.edit-tag", ["tag" => $editTag])
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<x-simple-card>
|
||||
<div class="text-center">
|
||||
{{ __("You don't have any tags yet!") }}
|
||||
</div>
|
||||
</x-simple-card>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
30
resources/views/settings/tags/tags.blade.php
Normal file
30
resources/views/settings/tags/tags.blade.php
Normal file
@ -0,0 +1,30 @@
|
||||
<div x-data="">
|
||||
<div class="inline-flex gap-1">
|
||||
<div
|
||||
id="tags-list-{{ $taggable->id }}"
|
||||
class="inline-flex gap-1"
|
||||
@if (! isset($oobOff) || ! $oobOff)
|
||||
hx-swap-oob="outerHTML"
|
||||
@endif
|
||||
>
|
||||
@foreach ($taggable->tags as $tag)
|
||||
<div
|
||||
class="border-{{ $tag->color }}-300 bg-{{ $tag->color }}-50 text-{{ $tag->color }}-500 dark:border-{{ $tag->color }}-600 dark:bg-{{ $tag->color }}-500 flex max-w-max items-center rounded-md border px-2 py-1 text-xs dark:bg-opacity-10"
|
||||
>
|
||||
<x-heroicon name="o-tag" class="mr-1 size-4" />
|
||||
{{ $tag->name }}
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if (isset($edit) && $edit)
|
||||
<a
|
||||
x-on:click="$dispatch('open-modal', 'manage-tags-modal')"
|
||||
class="flex max-w-max cursor-pointer items-center justify-center rounded-md border border-gray-300 bg-gray-50 px-2 py-1 text-xs text-gray-500 dark:border-gray-600 dark:bg-gray-500 dark:bg-opacity-10"
|
||||
>
|
||||
<x-heroicon name="o-pencil" class="h-3 w-3" />
|
||||
</a>
|
||||
@include("settings.tags.manage")
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
@ -1,6 +1,8 @@
|
||||
<x-site-layout :site="$site">
|
||||
<x-slot name="pageTitle">{{ __("Settings") }}</x-slot>
|
||||
|
||||
@include("site-settings.partials.site-details")
|
||||
|
||||
@include("site-settings.partials.change-php-version")
|
||||
|
||||
@include("site-settings.partials.update-aliases")
|
||||
|
@ -0,0 +1,56 @@
|
||||
<x-card id="server-details">
|
||||
<x-slot name="title">{{ __("Details") }}</x-slot>
|
||||
<x-slot name="description">
|
||||
{{ __("More details about your site") }}
|
||||
</x-slot>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>{{ __("Created At") }}</div>
|
||||
<div>
|
||||
<x-datetime :value="$site->created_at" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="py-5">
|
||||
<div class="border-t border-gray-200 dark:border-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>{{ __("Type") }}</div>
|
||||
<div class="capitalize">{{ $site->type }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="py-5">
|
||||
<div class="border-t border-gray-200 dark:border-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>{{ __("Site ID") }}</div>
|
||||
<div class="flex items-center">
|
||||
<span class="rounded-md bg-gray-100 p-1 dark:bg-gray-700">
|
||||
{{ $site->id }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="py-5">
|
||||
<div class="border-t border-gray-200 dark:border-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>{{ __("Status") }}</div>
|
||||
<div class="flex items-center">
|
||||
@include("sites.partials.site-status")
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="py-5">
|
||||
<div class="border-t border-gray-200 dark:border-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>{{ __("Tags") }}</div>
|
||||
<div>
|
||||
@include("settings.tags.tags", ["taggable" => $site, "edit" => true])
|
||||
</div>
|
||||
</div>
|
||||
</x-card>
|
@ -22,9 +22,8 @@ class="space-y-6"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<div id="vhost-container">
|
||||
<x-textarea id="vhost" name="vhost" rows="10" class="mt-1 block min-h-[400px] w-full font-mono">
|
||||
{{ session()->has("vhost") ? session()->get("vhost") : "Loading..." }}
|
||||
</x-textarea>
|
||||
@php($vhost = old("vhost", session()->get("vhost") ?? "Loading..."))
|
||||
<x-editor id="vhost" name="vhost" lang="nginx" :value="$vhost" />
|
||||
</div>
|
||||
@error("vhost")
|
||||
<x-input-error class="mt-2" :messages="$message" />
|
||||
|
@ -12,30 +12,63 @@
|
||||
<x-live id="live-sites">
|
||||
@if (count($sites) > 0)
|
||||
<div class="space-y-3">
|
||||
@foreach ($sites as $site)
|
||||
<a href="{{ route("servers.sites.show", ["server" => $server, "site" => $site]) }}" class="block">
|
||||
<x-item-card>
|
||||
<div class="flex-none">
|
||||
<img
|
||||
src="{{ asset("static/images/" . $site->type . ".svg") }}"
|
||||
class="h-10 w-10"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-3 flex flex-grow flex-col items-start justify-center">
|
||||
<span class="mb-1">{{ $site->domain }}</span>
|
||||
<span class="text-sm text-gray-400">
|
||||
<x-table>
|
||||
<x-thead>
|
||||
<x-tr>
|
||||
<x-th>Domain</x-th>
|
||||
<x-th>Date</x-th>
|
||||
<x-th>Tags</x-th>
|
||||
<x-th>Status</x-th>
|
||||
<x-th></x-th>
|
||||
</x-tr>
|
||||
</x-thead>
|
||||
<x-tbody>
|
||||
@foreach ($sites as $site)
|
||||
<x-tr>
|
||||
<x-td>
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
src="{{ asset("static/images/" . $site->type . ".svg") }}"
|
||||
class="mr-1 h-5 w-5"
|
||||
alt=""
|
||||
/>
|
||||
<a
|
||||
href="{{ route("servers.sites.show", ["server" => $server, "site" => $site]) }}"
|
||||
class="hover:underline"
|
||||
>
|
||||
{{ $site->domain }}
|
||||
</a>
|
||||
</div>
|
||||
</x-td>
|
||||
<x-td>
|
||||
<x-datetime :value="$site->created_at" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="inline">
|
||||
</x-td>
|
||||
<x-td>
|
||||
@include("settings.tags.tags", ["taggable" => $site, "oobOff" => true])
|
||||
</x-td>
|
||||
<x-td>
|
||||
@include("sites.partials.status", ["status" => $site->status])
|
||||
</div>
|
||||
</div>
|
||||
</x-item-card>
|
||||
</a>
|
||||
@endforeach
|
||||
</x-td>
|
||||
<x-td>
|
||||
<div class="flex items-center justify-end">
|
||||
<x-icon-button
|
||||
:href="route('servers.sites.show', ['server' => $server, 'site' => $site])"
|
||||
data-tooltip="Show Site"
|
||||
>
|
||||
<x-heroicon name="o-eye" class="h-5 w-5" />
|
||||
</x-icon-button>
|
||||
<x-icon-button
|
||||
:href="route('servers.sites.settings', ['server' => $server, 'site' => $site])"
|
||||
data-tooltip="Settings"
|
||||
>
|
||||
<x-heroicon name="o-wrench-screwdriver" class="h-5 w-5" />
|
||||
</x-icon-button>
|
||||
</div>
|
||||
</x-td>
|
||||
</x-tr>
|
||||
@endforeach
|
||||
</x-tbody>
|
||||
</x-table>
|
||||
</div>
|
||||
@else
|
||||
<x-simple-card>
|
||||
|
@ -6,6 +6,7 @@
|
||||
use App\Http\Controllers\Settings\SourceControlController;
|
||||
use App\Http\Controllers\Settings\SSHKeyController;
|
||||
use App\Http\Controllers\Settings\StorageProviderController;
|
||||
use App\Http\Controllers\Settings\TagController;
|
||||
use App\Http\Controllers\Settings\UserController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@ -64,3 +65,11 @@
|
||||
Route::post('add', [SshKeyController::class, 'add'])->name('settings.ssh-keys.add');
|
||||
Route::delete('delete/{id}', [SshKeyController::class, 'delete'])->name('settings.ssh-keys.delete');
|
||||
});
|
||||
|
||||
// tags
|
||||
Route::prefix('/tags')->group(function () {
|
||||
Route::get('/', [TagController::class, 'index'])->name('settings.tags');
|
||||
Route::post('/create', [TagController::class, 'create'])->name('settings.tags.create');
|
||||
Route::post('/{tag}', [TagController::class, 'update'])->name('settings.tags.update');
|
||||
Route::delete('/{tag}', [TagController::class, 'delete'])->name('settings.tags.delete');
|
||||
});
|
||||
|
@ -4,6 +4,7 @@
|
||||
use App\Http\Controllers\ScriptController;
|
||||
use App\Http\Controllers\SearchController;
|
||||
use App\Http\Controllers\Settings\ProjectController;
|
||||
use App\Http\Controllers\Settings\TagController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
@ -25,6 +26,11 @@
|
||||
require __DIR__.'/settings.php';
|
||||
});
|
||||
|
||||
Route::prefix('/settings/tags')->group(function () {
|
||||
Route::post('/attach', [TagController::class, 'attach'])->name('tags.attach');
|
||||
Route::post('/{tag}/detach', [TagController::class, 'detach'])->name('tags.detach');
|
||||
});
|
||||
|
||||
Route::prefix('/servers')->middleware('must-have-current-project')->group(function () {
|
||||
require __DIR__.'/server.php';
|
||||
});
|
||||
|
@ -24,7 +24,7 @@ fi
|
||||
|
||||
if [[ -z "${V_ADMIN_PASSWORD}" ]]; then
|
||||
echo "Enter a password for Vito's dashboard:"
|
||||
read -s V_ADMIN_PASSWORD
|
||||
read V_ADMIN_PASSWORD
|
||||
fi
|
||||
|
||||
if [[ -z "${V_ADMIN_PASSWORD}" ]]; then
|
||||
|
@ -6,7 +6,7 @@ echo "Pulling changes..."
|
||||
git fetch --all
|
||||
|
||||
echo "Checking out the latest tag..."
|
||||
NEW_RELEASE=$(git tag -l --merged 1.x --sort=-v:refname | head -n 1)
|
||||
NEW_RELEASE=$(git tag -l "1.*" --sort=-v:refname | head -n 1)
|
||||
git checkout "$NEW_RELEASE"
|
||||
|
||||
git pull origin "$NEW_RELEASE"
|
||||
|
@ -1,23 +1,42 @@
|
||||
import defaultTheme from 'tailwindcss/defaultTheme';
|
||||
import forms from '@tailwindcss/forms';
|
||||
import defaultTheme from "tailwindcss/defaultTheme";
|
||||
import forms from "@tailwindcss/forms";
|
||||
import colors from "tailwindcss/colors";
|
||||
import flowbite from 'flowbite/plugin';
|
||||
import flowbite from "flowbite/plugin";
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
/** @type {import("tailwindcss").Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
darkMode: "class",
|
||||
|
||||
safelist: [
|
||||
// Safelist all colors for text, background, border, etc.
|
||||
{
|
||||
pattern:
|
||||
/text-(red|green|blue|yellow|indigo|purple|pink|gray|white|black|orange|lime|emerald|teal|cyan|sky|violet|rose|fuchsia|amber|slate|zinc|neutral|stone)-(50|100|200|300|400|500|600|700|800|900)/,
|
||||
variants: ["dark"], // Ensure dark mode variants are also included
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/bg-(red|green|blue|yellow|indigo|purple|pink|gray|white|black|orange|lime|emerald|teal|cyan|sky|violet|rose|fuchsia|amber|slate|zinc|neutral|stone)-(50|100|200|300|400|500|600|700|800|900)/,
|
||||
variants: ["dark"],
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/border-(red|green|blue|yellow|indigo|purple|pink|gray|white|black|orange|lime|emerald|teal|cyan|sky|violet|rose|fuchsia|amber|slate|zinc|neutral|stone)-(50|100|200|300|400|500|600|700|800|900)/,
|
||||
variants: ["dark"],
|
||||
},
|
||||
],
|
||||
|
||||
content: [
|
||||
'./vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php',
|
||||
'./storage/framework/views/*.php',
|
||||
'./resources/views/**/*.blade.php',
|
||||
"./node_modules/flowbite/**/*.js"
|
||||
"./vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php",
|
||||
"./storage/framework/views/*.php",
|
||||
"./resources/views/**/*.blade.php",
|
||||
"./node_modules/flowbite/**/*.js",
|
||||
],
|
||||
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Figtree', ...defaultTheme.fontFamily.sans],
|
||||
sans: ["Figtree", ...defaultTheme.fontFamily.sans],
|
||||
},
|
||||
colors: {
|
||||
gray: colors.slate,
|
||||
@ -26,8 +45,8 @@ export default {
|
||||
},
|
||||
variants: {
|
||||
extend: {
|
||||
border: ['last'],
|
||||
}
|
||||
border: ["last"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -35,7 +54,7 @@ export default {
|
||||
plugins: [
|
||||
forms,
|
||||
flowbite({
|
||||
charts: true
|
||||
})
|
||||
charts: true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
@ -3,6 +3,7 @@
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\StorageProvider;
|
||||
use App\Facades\FTP;
|
||||
use App\Models\Backup;
|
||||
use App\Models\Database;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@ -24,9 +25,17 @@ public function test_create(array $input): void
|
||||
Http::fake();
|
||||
}
|
||||
|
||||
if ($input['provider'] === StorageProvider::FTP) {
|
||||
FTP::fake();
|
||||
}
|
||||
|
||||
$this->post(route('settings.storage-providers.connect'), $input)
|
||||
->assertSessionDoesntHaveErrors();
|
||||
|
||||
if ($input['provider'] === StorageProvider::FTP) {
|
||||
FTP::assertConnected($input['host']);
|
||||
}
|
||||
|
||||
$this->assertDatabaseHas('storage_providers', [
|
||||
'provider' => $input['provider'],
|
||||
'profile' => $input['name'],
|
||||
@ -113,33 +122,33 @@ public static function createData(): array
|
||||
'global' => 1,
|
||||
],
|
||||
],
|
||||
// [
|
||||
// [
|
||||
// 'provider' => StorageProvider::FTP,
|
||||
// 'name' => 'ftp-test',
|
||||
// 'host' => '1.2.3.4',
|
||||
// 'port' => '22',
|
||||
// 'path' => '/home/vito',
|
||||
// 'username' => 'username',
|
||||
// 'password' => 'password',
|
||||
// 'ssl' => 1,
|
||||
// 'passive' => 1,
|
||||
// ],
|
||||
// ],
|
||||
// [
|
||||
// [
|
||||
// 'provider' => StorageProvider::FTP,
|
||||
// 'name' => 'ftp-test',
|
||||
// 'host' => '1.2.3.4',
|
||||
// 'port' => '22',
|
||||
// 'path' => '/home/vito',
|
||||
// 'username' => 'username',
|
||||
// 'password' => 'password',
|
||||
// 'ssl' => 1,
|
||||
// 'passive' => 1,
|
||||
// 'global' => 1,
|
||||
// ],
|
||||
// ],
|
||||
[
|
||||
[
|
||||
'provider' => StorageProvider::FTP,
|
||||
'name' => 'ftp-test',
|
||||
'host' => '1.2.3.4',
|
||||
'port' => '22',
|
||||
'path' => '/home/vito',
|
||||
'username' => 'username',
|
||||
'password' => 'password',
|
||||
'ssl' => 1,
|
||||
'passive' => 1,
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
'provider' => StorageProvider::FTP,
|
||||
'name' => 'ftp-test',
|
||||
'host' => '1.2.3.4',
|
||||
'port' => '22',
|
||||
'path' => '/home/vito',
|
||||
'username' => 'username',
|
||||
'password' => 'password',
|
||||
'ssl' => 1,
|
||||
'passive' => 1,
|
||||
'global' => 1,
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
'provider' => StorageProvider::DROPBOX,
|
||||
|
201
tests/Feature/TagsTest.php
Normal file
201
tests/Feature/TagsTest.php
Normal file
@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\Site;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TagsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_create_tag(): void
|
||||
{
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$this->post(route('settings.tags.create'), [
|
||||
'name' => 'test',
|
||||
'color' => config('core.tag_colors')[0],
|
||||
])->assertSessionDoesntHaveErrors();
|
||||
|
||||
$this->assertDatabaseHas('tags', [
|
||||
'project_id' => $this->user->current_project_id,
|
||||
'name' => 'test',
|
||||
'color' => config('core.tag_colors')[0],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_get_tags_list(): void
|
||||
{
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$tag = Tag::factory()->create([
|
||||
'project_id' => $this->user->current_project_id,
|
||||
]);
|
||||
|
||||
$this->get(route('settings.tags'))
|
||||
->assertSuccessful()
|
||||
->assertSee($tag->name);
|
||||
}
|
||||
|
||||
public function test_delete_tag(): void
|
||||
{
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$tag = Tag::factory()->create([
|
||||
'project_id' => $this->user->current_project_id,
|
||||
]);
|
||||
|
||||
$this->delete(route('settings.tags.delete', $tag->id))
|
||||
->assertSessionDoesntHaveErrors();
|
||||
|
||||
$this->assertDatabaseMissing('tags', [
|
||||
'id' => $tag->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_create_tag_handles_invalid_color(): void
|
||||
{
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$this->post(route('settings.tags.create'), [
|
||||
'name' => 'test',
|
||||
'color' => 'invalid-color',
|
||||
])->assertSessionHasErrors('color');
|
||||
}
|
||||
|
||||
public function test_create_tag_handles_invalid_name(): void
|
||||
{
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$this->post(route('settings.tags.create'), [
|
||||
'name' => '',
|
||||
'color' => config('core.tag_colors')[0],
|
||||
])->assertSessionHasErrors('name');
|
||||
}
|
||||
|
||||
public function test_edit_tag(): void
|
||||
{
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$tag = Tag::factory()->create([
|
||||
'project_id' => $this->user->current_project_id,
|
||||
]);
|
||||
|
||||
$this->post(route('settings.tags.update', ['tag' => $tag]), [
|
||||
'name' => 'New Name',
|
||||
'color' => config('core.tag_colors')[1],
|
||||
])
|
||||
->assertSessionDoesntHaveErrors();
|
||||
|
||||
$this->assertDatabaseHas('tags', [
|
||||
'id' => $tag->id,
|
||||
'name' => 'New Name',
|
||||
'color' => config('core.tag_colors')[1],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider data
|
||||
*/
|
||||
public function test_attach_existing_tag_to_taggable(array $input): void
|
||||
{
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$tag = Tag::factory()->create([
|
||||
'project_id' => $this->user->current_project_id,
|
||||
'name' => $input['name'],
|
||||
]);
|
||||
|
||||
$input['taggable_id'] = match ($input['taggable_type']) {
|
||||
Server::class => $this->server->id,
|
||||
Site::class => $this->site->id,
|
||||
default => $this->fail('Unknown taggable type'),
|
||||
};
|
||||
|
||||
$this->post(route('tags.attach'), $input)->assertSessionDoesntHaveErrors();
|
||||
|
||||
$this->assertDatabaseHas('taggables', [
|
||||
'taggable_id' => $input['taggable_id'],
|
||||
'tag_id' => $tag->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider data
|
||||
*/
|
||||
public function test_attach_new_tag_to_taggable(array $input): void
|
||||
{
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$input['taggable_id'] = match ($input['taggable_type']) {
|
||||
Server::class => $this->server->id,
|
||||
Site::class => $this->site->id,
|
||||
default => $this->fail('Unknown taggable type'),
|
||||
};
|
||||
|
||||
$this->post(route('tags.attach'), $input)->assertSessionDoesntHaveErrors();
|
||||
|
||||
$this->assertDatabaseHas('tags', [
|
||||
'name' => $input['name'],
|
||||
]);
|
||||
|
||||
$tag = Tag::query()->where('name', $input['name'])->firstOrFail();
|
||||
|
||||
$this->assertDatabaseHas('taggables', [
|
||||
'taggable_id' => $input['taggable_id'],
|
||||
'tag_id' => $tag->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider data
|
||||
*/
|
||||
public function test_detach_tag(array $input): void
|
||||
{
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$tag = Tag::factory()->create([
|
||||
'project_id' => $this->user->current_project_id,
|
||||
'name' => $input['name'],
|
||||
]);
|
||||
|
||||
$taggable = match ($input['taggable_type']) {
|
||||
Server::class => $this->server,
|
||||
Site::class => $this->site,
|
||||
default => $this->fail('Unknown taggable type'),
|
||||
};
|
||||
|
||||
$input['taggable_id'] = $taggable->id;
|
||||
|
||||
$taggable->tags()->attach($tag);
|
||||
|
||||
$this->post(route('tags.detach', $tag->id), $input)->assertSessionDoesntHaveErrors();
|
||||
|
||||
$this->assertDatabaseMissing('taggables', [
|
||||
'taggable_id' => $input['taggable_id'],
|
||||
'tag_id' => $tag->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function data(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
[
|
||||
'taggable_type' => Server::class,
|
||||
'name' => 'staging',
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
'taggable_type' => Site::class,
|
||||
'name' => 'production',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user