feat: manage labels (monicahq/chandler#9)

This commit is contained in:
Regis Freyd
2021-12-15 22:08:17 -05:00
committed by GitHub
parent 6d14076945
commit d16a4e651b
16 changed files with 1896 additions and 25 deletions
@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers\Settings\Personalize\Labels;
use Inertia\Inertia;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Auth;
use App\Services\Account\ManageLabels\CreateLabel;
use App\Services\Account\ManageLabels\UpdateLabel;
use App\Services\Account\ManageLabels\DestroyLabel;
use App\Http\Controllers\Vault\ViewHelpers\VaultIndexViewHelper;
use App\Http\Controllers\Settings\Personalize\Labels\ViewHelpers\PersonalizeLabelIndexViewHelper;
class PersonalizeLabelController extends Controller
{
public function index()
{
return Inertia::render('Settings/Personalize/Labels/Index', [
'layoutData' => VaultIndexViewHelper::layoutData(),
'data' => PersonalizeLabelIndexViewHelper::data(Auth::user()->account),
]);
}
public function store(Request $request)
{
$data = [
'account_id' => Auth::user()->account_id,
'author_id' => Auth::user()->id,
'name' => $request->input('name'),
'description' => $request->input('description'),
];
$label = (new CreateLabel)->execute($data);
return response()->json([
'data' => PersonalizeLabelIndexViewHelper::dtoLabel($label),
], 201);
}
public function update(Request $request, int $labelId)
{
$data = [
'account_id' => Auth::user()->account_id,
'author_id' => Auth::user()->id,
'label_id' => $labelId,
'name' => $request->input('name'),
'description' => $request->input('description'),
];
$label = (new UpdateLabel)->execute($data);
return response()->json([
'data' => PersonalizeLabelIndexViewHelper::dtoLabel($label),
], 200);
}
public function destroy(Request $request, int $labelId)
{
$data = [
'account_id' => Auth::user()->account_id,
'author_id' => Auth::user()->id,
'label_id' => $labelId,
];
(new DestroyLabel)->execute($data);
return response()->json([
'data' => true,
], 200);
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\Settings\Personalize\Labels\ViewHelpers;
use App\Models\Label;
use App\Models\Account;
class PersonalizeLabelIndexViewHelper
{
public static function data(Account $account): array
{
$labels = $account->labels()
->withCount('contacts')
->orderBy('name', 'asc')
->get();
$collection = collect();
foreach ($labels as $label) {
$collection->push(self::dtoLabel($label));
}
return [
'labels' => $collection,
'url' => [
'settings' => route('settings.index'),
'personalize' => route('settings.personalize.index'),
'label_store' => route('settings.personalize.label.store'),
],
];
}
public static function dtoLabel(Label $label): array
{
return [
'id' => $label->id,
'name' => $label->name,
'count' => $label->contacts_count,
'url' => [
'update' => route('settings.personalize.label.update', [
'label' => $label->id,
]),
'destroy' => route('settings.personalize.label.destroy', [
'label' => $label->id,
]),
],
];
}
}
@@ -11,6 +11,7 @@ class PersonalizeIndexViewHelper
'settings' => route('settings.index'),
'back' => route('settings.index'),
'manage_relationships' => route('settings.personalize.relationship.index'),
'manage_labels' => route('settings.personalize.label.index'),
],
];
}
@@ -2,7 +2,6 @@
namespace App\Services\Account\ManageLabels;
use App\Models\User;
use App\Models\Label;
use Illuminate\Support\Str;
use App\Jobs\CreateAuditLog;
@@ -2,8 +2,8 @@
namespace App\Services\Account\ManageLabels;
use App\Models\User;
use App\Models\Label;
use Illuminate\Support\Str;
use App\Jobs\CreateAuditLog;
use App\Services\BaseService;
use App\Interfaces\ServiceInterface;
@@ -22,6 +22,7 @@ class UpdateLabel extends BaseService implements ServiceInterface
'author_id' => 'required|integer|exists:users,id',
'label_id' => 'required|integer|exists:labels,id',
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:65535',
];
}
@@ -52,6 +53,8 @@ class UpdateLabel extends BaseService implements ServiceInterface
->findOrFail($data['label_id']);
$label->name = $data['name'];
$label->description = $this->valueOrNull($data, 'description');
$label->slug = Str::slug($data['name'], '-');
$label->save();
CreateAuditLog::dispatch([
@@ -8,7 +8,6 @@ use App\Interfaces\ServiceInterface;
class StoreNameOrderPreference extends BaseService implements ServiceInterface
{
private User $user;
private array $data;
/**
+2
View File
@@ -29,7 +29,9 @@
"eslint-plugin-promise": ">=4.2.1",
"eslint-plugin-standard": ">=4.0.0",
"eslint-plugin-vue": "^7.0.1",
"husky": "^4.3.0",
"laravel-mix": "^6.0.6",
"lint-staged": "^8.2.1",
"lodash": "^4.17.19",
"postcss": "^8.4.4",
"postcss-import": "^14.0.1",
@@ -30,9 +30,10 @@
<main class="sm:mt-20 relative">
<div class="max-w-md mx-auto px-2 py-2 sm:py-6 sm:px-6 lg:px-8">
<h2 class="text-lg text-center mb-6">Personalize the account</h2>
<div class="bg-white border border-gray-200 rounded-lg mb-6 p-5">
<div class="bg-white border border-gray-200 rounded-lg p-5">
<ul>
<li class="mb-2"><span class="mr-1">🥸</span> <Link :href="data.url.manage_relationships" class="text-sky-500 hover:text-blue-900">Manage relationship types</Link></li>
<li class=""><span class="mr-1">🏷</span> <Link :href="data.url.manage_labels" class="text-sky-500 hover:text-blue-900">Manage labels</Link></li>
</ul>
</div>
</div>
@@ -0,0 +1,241 @@
<style lang="scss" scoped>
.item-list {
&:hover:first-child {
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
&:last-child {
border-bottom: 0;
}
&:hover:last-child {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
}
</style>
<template>
<Layout :layoutData="layoutData">
<!-- breadcrumb -->
<nav class="sm:border-b bg-white">
<div class="max-w-8xl mx-auto px-4 sm:px-6 py-2 hidden md:block">
<div class="flex items-baseline justify-between space-x-6">
<ul class="text-sm">
<li class="inline mr-2 text-gray-600">You are here:</li>
<li class="inline mr-2">
<Link :href="data.url.settings" class="text-sky-500 hover:text-blue-900">Settings</Link>
</li>
<li class="inline mr-2 relative">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 inline relative icon-breadcrumb" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</li>
<li class="inline mr-2"><Link :href="data.url.personalize" class="text-sky-500 hover:text-blue-900">Personalize your account</Link></li>
<li class="inline mr-2 relative">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 inline relative icon-breadcrumb" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</li>
<li class="inline">Labels</li>
</ul>
</div>
</div>
</nav>
<main class="sm:mt-20 relative">
<div class="max-w-3xl mx-auto px-2 py-2 sm:py-6 sm:px-6 lg:px-8">
<!-- title + cta -->
<div class="sm:flex items-center justify-between mb-6 sm:mt-0 mt-8">
<h3 class="mb-4 sm:mb-0"><span class="mr-1">🏷</span> All the labels used in the account</h3>
<pretty-button @click="showLabelModal" v-if="!createlabelModalShown" :text="'Add a label'" :icon="'plus'" />
</div>
<!-- modal to create a new group type -->
<form v-if="createlabelModalShown" @submit.prevent="submit()" class="bg-white border border-gray-200 rounded-lg mb-6">
<div class="p-5 border-b border-gray-200">
<errors :errors="form.errors" />
<text-input v-model="form.name"
:label="'Name'"
:type="'text'" :autofocus="true"
:input-class="'block w-full'"
:required="true"
:ref="'newLabel'"
:autocomplete="false"
:maxlength="255"
@esc-key-pressed="createlabelModalShown = false" />
</div>
<div class="p-5 flex justify-between">
<pretty-span @click="createlabelModalShown = false" :text="'Cancel'" :classes="'mr-3'" />
<pretty-button :text="'Create label'" :state="loadingState" :icon="'plus'" :classes="'save'" />
</div>
</form>
<!-- list of groups types -->
<ul v-if="localLabels.length > 0" class="bg-white border border-gray-200 rounded-lg mb-6">
<li v-for="label in localLabels" :key="label.id" class="border-b border-gray-200 hover:bg-slate-50 item-list">
<!-- detail of the group type -->
<div v-if="renamelabelModalShownId != label.id" class="flex justify-between items-center px-5 py-2">
<span class="text-base">{{ label.name }} <span class="text-xs text-gray-500">({{ label.count }} contacts)</span></span>
<!-- actions -->
<ul class="text-sm">
<li @click="updateLabelModal(label)" class="cursor-pointer inline mr-4 text-sky-500 hover:text-blue-900">Rename</li>
<li @click="destroy(label)" class="cursor-pointer inline text-red-500 hover:text-red-900">Delete</li>
</ul>
</div>
<!-- rename a label modal -->
<form v-if="renamelabelModalShownId == label.id" @submit.prevent="update(label)" class="border-b border-gray-200 hover:bg-slate-50 item-list">
<div class="p-5 border-b border-gray-200">
<errors :errors="form.errors" />
<text-input v-model="form.name"
:label="'Name'"
:type="'text'" :autofocus="true"
:input-class="'block w-full'"
:required="true"
:ref="'rename' + label.id"
:autocomplete="false"
:maxlength="255"
@esc-key-pressed="renamelabelModalShownId = 0" />
</div>
<div class="p-5 flex justify-between">
<pretty-span @click.prevent="renamelabelModalShownId = 0" :text="'Cancel'" :classes="'mr-3'" />
<pretty-button :text="'Rename'" :state="loadingState" :icon="'check'" :classes="'save'" />
</div>
</form>
</li>
</ul>
<!-- blank state -->
<div v-if="localLabels.length == 0" class="bg-white border border-gray-200 rounded-lg mb-6">
<p class="p-5 text-center">Labels let you classify contacts using a system that matters to you.</p>
</div>
</div>
</main>
</Layout>
</template>
<script>
import Layout from '@/Shared/Layout';
import { Link } from '@inertiajs/inertia-vue3';
import PrettyButton from '@/Shared/PrettyButton';
import PrettyLink from '@/Shared/PrettyLink';
import PrettySpan from '@/Shared/PrettySpan';
import TextInput from '@/Shared/TextInput';
import Errors from '@/Shared/Errors';
export default {
components: {
Layout,
Link,
PrettyButton,
PrettyLink,
PrettySpan,
TextInput,
Errors,
},
props: {
layoutData: {
type: Object,
default: null,
},
data: {
type: Object,
default: null,
},
},
data() {
return {
loadingState: '',
createlabelModalShown: false,
renamelabelModalShownId: 0,
localLabels: [],
form: {
name: '',
description: '',
errors: [],
},
};
},
mounted() {
this.localLabels = this.data.labels;
},
methods: {
showLabelModal() {
this.form.name = '';
this.createlabelModalShown = true;
this.$nextTick(() => {
this.$refs.newLabel.focus();
});
},
updateLabelModal(label) {
this.form.name = label.name;
this.renamelabelModalShownId = label.id;
this.$nextTick(() => {
this.$refs[`rename${label.id}`].focus();
});
},
submit() {
this.loadingState = 'loading';
axios.post(this.data.url.label_store, this.form)
.then(response => {
this.flash('The label has been created', 'success');
this.localLabels.unshift(response.data.data);
this.loadingState = null;
this.createlabelModalShown = false;
})
.catch(error => {
this.loadingState = null;
this.form.errors = error.response.data;
});
},
update(label) {
this.loadingState = 'loading';
axios.put(label.url.update, this.form)
.then(response => {
this.flash('The label has been updated', 'success');
this.localLabels[this.localLabels.findIndex(x => x.id === label.id)] = response.data.data;
this.loadingState = null;
this.renamelabelModalShownId = 0;
})
.catch(error => {
this.loadingState = null;
this.form.errors = error.response.data;
});
},
destroy(label) {
if(confirm("Are you sure? This will remove the labels from all contacts, but won't delete the contacts themselves.")) {
axios.delete(label.url.destroy)
.then(response => {
this.flash('The label has been deleted', 'success');
var id = this.localLabels.findIndex(x => x.id === label.id);
this.localLabels.splice(id, 1);
})
.catch(error => {
this.loadingState = null;
this.form.errors = error.response.data;
});
}
},
},
};
</script>
@@ -90,7 +90,7 @@
</div>
<div class="p-5 flex justify-between">
<pretty-link @click="createGroupTypeModalShown = false" :text="'Cancel'" :classes="'mr-3'" />
<pretty-span @click="createGroupTypeModalShown = false" :text="'Cancel'" :classes="'mr-3'" />
<pretty-button :text="'Create group type'" :state="loadingState" :icon="'plus'" :classes="'save'" />
</div>
</form>
@@ -245,7 +245,6 @@
import Layout from '@/Shared/Layout';
import { Link } from '@inertiajs/inertia-vue3';
import PrettyButton from '@/Shared/PrettyButton';
import PrettyLink from '@/Shared/PrettyLink';
import PrettySpan from '@/Shared/PrettySpan';
import TextInput from '@/Shared/TextInput';
import Errors from '@/Shared/Errors';
@@ -255,7 +254,6 @@ export default {
Layout,
Link,
PrettyButton,
PrettyLink,
PrettySpan,
TextInput,
Errors,
@@ -41,6 +41,9 @@ pre {
<!-- edit mode -->
<form v-if="editMode" @submit.prevent="submit()" class="bg-white border border-gray-200 rounded-lg mb-6">
<div class="px-5 py-2 border-b border-gray-200">
<Errors :errors="form.errors" />
<div class="flex items-center mb-2">
<input v-model="form.nameOrder" id="first_name_last_name" value="%first_name% %last_name%" name="name-order" type="radio" class="h-4 w-4 text-sky-500 border-gray-300">
<label for="first_name_last_name" class="ml-3 block text-sm font-medium text-gray-700 cursor-pointer">
+7
View File
@@ -10,6 +10,7 @@ use App\Http\Controllers\Auth\AcceptInvitationController;
use App\Http\Controllers\Settings\Personalize\PersonalizeController;
use App\Http\Controllers\Settings\Preferences\PreferencesController;
use App\Http\Controllers\Settings\CancelAccount\CancelAccountController;
use App\Http\Controllers\Settings\Personalize\Labels\PersonalizeLabelController;
use App\Http\Controllers\Settings\Personalize\Relationships\PersonalizeRelationshipController;
use App\Http\Controllers\Settings\Personalize\Relationships\PersonalizeRelationshipTypeController;
@@ -73,6 +74,12 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('relationships/{groupType}/types', [PersonalizeRelationshipTypeController::class, 'store'])->name('settings.personalize.relationship.type.store');
Route::put('relationships/{groupType}/types/{type}', [PersonalizeRelationshipTypeController::class, 'update'])->name('settings.personalize.relationship.type.update');
Route::delete('relationships/{groupType}/types/{type}', [PersonalizeRelationshipTypeController::class, 'destroy'])->name('settings.personalize.relationship.type.destroy');
// labels
Route::get('labels', [PersonalizeLabelController::class, 'index'])->name('settings.personalize.label.index');
Route::post('labels', [PersonalizeLabelController::class, 'store'])->name('settings.personalize.label.store');
Route::put('labels/{label}', [PersonalizeLabelController::class, 'update'])->name('settings.personalize.label.update');
Route::delete('labels/{label}', [PersonalizeLabelController::class, 'destroy'])->name('settings.personalize.label.destroy');
});
// cancel
@@ -0,0 +1,57 @@
<?php
namespace Tests\Unit\Controllers\Settings\Personalize\Labels\ViewHelpers;
use function env;
use Tests\TestCase;
use App\Models\Label;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use App\Http\Controllers\Settings\Personalize\Labels\ViewHelpers\PersonalizeLabelIndexViewHelper;
class PersonalizeLabelIndexViewHelperTest extends TestCase
{
use DatabaseTransactions;
/** @test */
public function it_gets_the_data_needed_for_the_view(): void
{
$label = Label::factory()->create();
$array = PersonalizeLabelIndexViewHelper::data($label->account);
$this->assertEquals(
2,
count($array)
);
$this->assertArrayHasKey('labels', $array);
$this->assertEquals(
[
'settings' => env('APP_URL').'/settings',
'personalize' => env('APP_URL').'/settings/personalize',
'label_store' => env('APP_URL').'/settings/personalize/labels',
],
$array['url']
);
}
/** @test */
public function it_gets_the_data_needed_for_the_data_transfer_object(): void
{
$label = Label::factory()->create();
$array = PersonalizeLabelIndexViewHelper::dtoLabel($label);
$this->assertEquals(
[
'id' => $label->id,
'name' => $label->name,
'count' => 0,
'url' => [
'update' => route('settings.personalize.label.update', [
'label' => $label->id,
]),
'destroy' => route('settings.personalize.label.destroy', [
'label' => $label->id,
]),
],
],
$array
);
}
}
@@ -21,6 +21,7 @@ class PersonalizeIndexViewHelperTest extends TestCase
'settings' => env('APP_URL').'/settings',
'back' => env('APP_URL').'/settings',
'manage_relationships' => env('APP_URL').'/settings/personalize/relationships',
'manage_labels' => env('APP_URL').'/settings/personalize/labels',
],
],
$array
@@ -0,0 +1,86 @@
<?php
namespace Tests\Unit\Services\User;
use Tests\TestCase;
use App\Models\User;
use App\Models\Account;
use Illuminate\Support\Facades\Queue;
use Illuminate\Validation\ValidationException;
use App\Services\User\StoreNameOrderPreference;
use App\Services\Account\ManageLabels\CreateLabel;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class StoreNameOrderPreferenceTest extends TestCase
{
use DatabaseTransactions;
/** @test */
public function it_stores_the_name_order_preference(): void
{
$ross = $this->createUser();
$this->executeService($ross, '%name%', $ross->account);
}
/** @test */
public function it_fails_if_wrong_parameters_are_given(): void
{
$request = [
'title' => 'Ross',
];
$this->expectException(ValidationException::class);
(new CreateLabel)->execute($request);
}
/** @test */
public function it_fails_if_user_doesnt_belong_to_account(): void
{
$this->expectException(ModelNotFoundException::class);
$ross = $this->createAdministrator();
$account = $this->createAccount();
$this->executeService($ross, '%user%', $account);
}
public function it_fails_if_name_order_has_no_variable(): void
{
$this->expectException(ModelNotFoundException::class);
$ross = $this->createAdministrator();
$this->executeService($ross, '', $ross->account);
}
public function it_fails_if_name_order_has_no_closing_percent_symbol(): void
{
$this->expectException(ModelNotFoundException::class);
$ross = $this->createAdministrator();
$this->executeService($ross, '%', $ross->account);
}
private function executeService(User $author, string $nameOrder, Account $account): void
{
Queue::fake();
$request = [
'account_id' => $account->id,
'author_id' => $author->id,
'name_order' => $nameOrder,
];
$user = (new StoreNameOrderPreference)->execute($request);
$this->assertDatabaseHas('users', [
'id' => $user->id,
'account_id' => $account->id,
'name_order' => $nameOrder,
]);
$this->assertInstanceOf(
User::class,
$user
);
}
}
+1371 -18
View File
File diff suppressed because it is too large Load Diff