v1.4.0
Co nowego: - Wsparcie Active Directory dla LDAP (obok LLDAP/OpenLDAP), przełącznik typu katalogu w Admin > Integracje. - Wyszukiwarka klientów dla operatora (Operator > Klienci). - Stronicowanie kolejki operatora (50/stronę) i dashboardu klienta (20/stronę). - Globalna wyszukiwarka zgłoszeń (Ctrl+K/Cmd+K) z operatorami w stylu Gmaila (od:, temat:, treść:, numer:), plus przycisk "Szukaj" w panelu bocznym. - Ostatnio przeglądane zgłoszenia w panelu bocznym operatora. - Przeprojektowany pasek nawigacji: suwak Klient/Operator/Administrator zamiast rozwijanego menu, bogatsze menu profilu (nazwa/e-mail/role), dynamiczne tytuły kart przeglądarki na każdej podstronie. - Narzędzie do jednorazowego importu historii zgłoszeń z Heska 3.x (scripts/hesk-import/). - Poprawka: paginacja pokazywała surowe klucze tłumaczeń zamiast tekstu (brakujący lang/pl/pagination.php). Zaktualizowana dokumentacja: README, CLAUDE.md, install.md, ARCHITECTURE.md, CHANGELOG.md, wiki/*. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
147
src/tests/Feature/GlobalSearchAndRecentlyViewedTest.php
Normal file
147
src/tests/Feature/GlobalSearchAndRecentlyViewedTest.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\GlobalSearch;
|
||||
use App\Livewire\Operator\Queue;
|
||||
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('opening a ticket records a view that surfaces in the operator sidebar, most recent first', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('recent-view@example.com');
|
||||
$ticketA = makeTicket(['number' => '1001', 'subject' => 'First one']);
|
||||
$ticketB = makeTicket(['number' => '1002', 'subject' => 'Second one']);
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticketA]);
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticketB]);
|
||||
|
||||
$queue = Livewire::actingAs($operator)->test(Queue::class);
|
||||
$recent = $queue->instance()->recentlyViewed;
|
||||
|
||||
expect($recent->pluck('id')->all())->toBe([$ticketB->id, $ticketA->id]);
|
||||
});
|
||||
|
||||
test('re-opening the same ticket bumps it to the top instead of duplicating it', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('recent-view-bump@example.com');
|
||||
$ticketA = makeTicket(['number' => '1001']);
|
||||
$ticketB = makeTicket(['number' => '1002']);
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticketA]);
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticketB]);
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticketA]);
|
||||
|
||||
$recent = Livewire::actingAs($operator)->test(Queue::class)->instance()->recentlyViewed;
|
||||
|
||||
expect($recent->pluck('id')->all())->toBe([$ticketA->id, $ticketB->id]);
|
||||
});
|
||||
|
||||
test('global search only returns tickets the searching user is allowed to see', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('search-operator@example.com');
|
||||
$clientA = User::query()->create(['name' => 'Client A', 'email' => 'client-a@example.com', 'roles' => ['client']]);
|
||||
$clientB = User::query()->create(['name' => 'Client B', 'email' => 'client-b@example.com', 'roles' => ['client']]);
|
||||
|
||||
$ticketA = makeTicket(['number' => '2001', 'subject' => 'Unikalny temat A', 'customer_id' => $clientA->id, 'email' => $clientA->email]);
|
||||
makeTicket(['number' => '2002', 'subject' => 'Unikalny temat B', 'customer_id' => $clientB->id, 'email' => $clientB->email]);
|
||||
|
||||
// Operator sees both, scoped only by the search term.
|
||||
$operatorResults = Livewire::actingAs($operator)->test(GlobalSearch::class)
|
||||
->set('search', 'Unikalny')
|
||||
->instance()->results;
|
||||
expect($operatorResults)->toHaveCount(2);
|
||||
|
||||
// Client A only ever sees their own ticket, even when the term matches both.
|
||||
$clientResults = Livewire::actingAs($clientA)->test(GlobalSearch::class)
|
||||
->set('search', 'Unikalny')
|
||||
->instance()->results;
|
||||
expect($clientResults->pluck('id')->all())->toBe([$ticketA->id]);
|
||||
});
|
||||
|
||||
test('global search returns nothing for a blank query', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('search-blank@example.com');
|
||||
makeTicket(['number' => '3001']);
|
||||
|
||||
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)->instance()->results;
|
||||
|
||||
expect($results)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('the od: operator matches the reporter, not ticket content', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('search-od@example.com');
|
||||
$byKacper = makeTicket(['number' => '4001', 'name' => 'Kacper Zbikowski', 'email' => 'kacper@example.com', 'subject' => 'Nieistotny temat']);
|
||||
makeTicket(['number' => '4002', 'name' => 'Ktos Inny', 'email' => 'ktos@example.com', 'subject' => 'Kacper w temacie']);
|
||||
|
||||
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
|
||||
->set('search', 'od:Kacper')
|
||||
->instance()->results;
|
||||
|
||||
expect($results->pluck('id')->all())->toBe([$byKacper->id]);
|
||||
});
|
||||
|
||||
test('the temat: operator matches only the subject', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('search-temat@example.com');
|
||||
$matching = makeTicket(['number' => '4101', 'subject' => 'Awaria drukarki', 'body' => 'coś innego']);
|
||||
makeTicket(['number' => '4102', 'subject' => 'Coś innego', 'body' => 'Awaria drukarki w opisie']);
|
||||
|
||||
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
|
||||
->set('search', 'temat:drukarki')
|
||||
->instance()->results;
|
||||
|
||||
expect($results->pluck('id')->all())->toBe([$matching->id]);
|
||||
});
|
||||
|
||||
test('the treść: operator matches only ticket/message body, not the subject', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('search-tresc@example.com');
|
||||
$matching = makeTicket(['number' => '4201', 'subject' => 'Coś innego', 'body' => 'Drukarka nie działa od rana']);
|
||||
makeTicket(['number' => '4202', 'subject' => 'Drukarka znów nie działa', 'body' => 'coś innego']);
|
||||
|
||||
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
|
||||
->set('search', 'treść:drukarka')
|
||||
->instance()->results;
|
||||
|
||||
expect($results->pluck('id')->all())->toBe([$matching->id]);
|
||||
});
|
||||
|
||||
test('the numer: operator matches the ticket number', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('search-numer@example.com');
|
||||
$matching = makeTicket(['number' => '9999']);
|
||||
makeTicket(['number' => '1111']);
|
||||
|
||||
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
|
||||
->set('search', 'numer:9999')
|
||||
->instance()->results;
|
||||
|
||||
expect($results->pluck('id')->all())->toBe([$matching->id]);
|
||||
});
|
||||
|
||||
test('operators combine with AND, matching Gmail-style multi-field search', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('search-combo@example.com');
|
||||
$matching = makeTicket(['number' => '4301', 'name' => 'Kacper Zbikowski', 'subject' => 'Awaria drukarki']);
|
||||
makeTicket(['number' => '4302', 'name' => 'Kacper Zbikowski', 'subject' => 'Inny temat']);
|
||||
makeTicket(['number' => '4303', 'name' => 'Ktos Inny', 'subject' => 'Awaria drukarki']);
|
||||
|
||||
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
|
||||
->set('search', 'od:Kacper temat:drukarki')
|
||||
->instance()->results;
|
||||
|
||||
expect($results->pluck('id')->all())->toBe([$matching->id]);
|
||||
});
|
||||
|
||||
test('an unrecognized "key:value" token is treated as free text instead of being silently dropped', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('search-unrecognized@example.com');
|
||||
$matching = makeTicket(['number' => '4401', 'subject' => 'status:pilne coś']);
|
||||
|
||||
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
|
||||
->set('search', 'status:pilne')
|
||||
->instance()->results;
|
||||
|
||||
expect($results->pluck('id')->all())->toBe([$matching->id]);
|
||||
});
|
||||
47
src/tests/Feature/OperatorClientSearchTest.php
Normal file
47
src/tests/Feature/OperatorClientSearchTest.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Operator\ClientSearch;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('searching by name or email finds matching accounts, empty search shows nothing', function () {
|
||||
$operator = operatorUser('client-search-op@example.com');
|
||||
$anna = User::query()->create(['name' => 'Anna Kowalska', 'email' => 'anna.kowalska@example.com', 'roles' => ['client']]);
|
||||
User::query()->create(['name' => 'Jan Nowak', 'email' => 'jan.nowak@example.com', 'roles' => ['client']]);
|
||||
|
||||
$component = Livewire::actingAs($operator)->test(ClientSearch::class);
|
||||
expect($component->instance()->results)->toHaveCount(0);
|
||||
|
||||
$component->set('search', 'Kowalska');
|
||||
expect($component->instance()->results->pluck('id')->all())->toBe([$anna->id]);
|
||||
|
||||
$component->set('search', 'jan.nowak@example.com');
|
||||
expect($component->instance()->results)->toHaveCount(1);
|
||||
|
||||
$component->set('search', 'nobody-matches-this');
|
||||
expect($component->instance()->results)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('results include the ticket count and are capped at 20', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('client-search-op2@example.com');
|
||||
$client = User::query()->create(['name' => 'Piotr Testowy', 'email' => 'piotr@example.com', 'roles' => ['client']]);
|
||||
makeTicket(['number' => '2001', 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]);
|
||||
makeTicket(['number' => '2002', 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]);
|
||||
|
||||
foreach (range(1, 25) as $i) {
|
||||
User::query()->create(['name' => "Testowy Kandydat {$i}", 'email' => "cand{$i}@example.com", 'roles' => ['client']]);
|
||||
}
|
||||
|
||||
$component = Livewire::actingAs($operator)->test(ClientSearch::class)->set('search', 'Testowy');
|
||||
expect($component->instance()->results)->toHaveCount(20);
|
||||
|
||||
$component->set('search', 'Piotr');
|
||||
expect($component->instance()->results->first()->tickets_as_customer_count)->toBe(2);
|
||||
});
|
||||
|
||||
test('a client cannot reach the operator client-search page', function () {
|
||||
$client = User::query()->create(['name' => 'Client', 'email' => 'not-operator@example.com', 'roles' => ['client']]);
|
||||
|
||||
$this->actingAs($client)->get(route('operator.clients'))->assertForbidden();
|
||||
});
|
||||
46
src/tests/Feature/PageTitleTest.php
Normal file
46
src/tests/Feature/PageTitleTest.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
|
||||
function assertTitle(TestResponse $response, string $expectedContext): void
|
||||
{
|
||||
$response->assertOk();
|
||||
preg_match('#<title>(.*?)</title>#s', $response->getContent(), $m);
|
||||
expect($m[1] ?? null)->toBe($expectedContext.' — Servicedesk');
|
||||
}
|
||||
|
||||
test('login and landing pages get a page-specific title', function () {
|
||||
assertTitle($this->get('/login'), 'Logowanie');
|
||||
assertTitle($this->get('/'), 'Zgłoś problem');
|
||||
});
|
||||
|
||||
test('client pages carry a distinct, ticket-specific title', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$client = User::query()->create(['name' => 'Client', 'email' => 'title-client@example.com', 'roles' => ['client']]);
|
||||
$ticket = makeTicket(['number' => '6001', 'subject' => 'Drukarka nie działa', 'customer_id' => $client->id, 'email' => $client->email]);
|
||||
|
||||
assertTitle($this->actingAs($client)->get('/client'), 'Moje zgłoszenia');
|
||||
assertTitle($this->actingAs($client)->get('/client/new'), 'Nowe zgłoszenie');
|
||||
assertTitle($this->actingAs($client)->get(route('client.ticket', $ticket)), '#6001 — Drukarka nie działa');
|
||||
});
|
||||
|
||||
test('operator queue title reflects the currently selected queue', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('title-operator@example.com');
|
||||
$ticket = makeTicket(['number' => '6101', 'subject' => 'Awaria monitora']);
|
||||
|
||||
assertTitle($this->actingAs($operator)->get('/operator'), 'Otwarte');
|
||||
assertTitle($this->actingAs($operator)->get('/operator?queue=mine'), 'Moje zgłoszenia');
|
||||
assertTitle($this->actingAs($operator)->get('/operator/new'), 'Nowe zgłoszenie');
|
||||
assertTitle($this->actingAs($operator)->get('/operator/stats'), 'Statystyki');
|
||||
assertTitle($this->actingAs($operator)->get('/operator/clients'), 'Klienci');
|
||||
assertTitle($this->actingAs($operator)->get(route('operator.ticket', $ticket)), '#6101 — Awaria monitora');
|
||||
});
|
||||
|
||||
test('admin panel title reflects the currently selected tab', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
assertTitle($this->actingAs($admin)->get('/admin'), 'Kategorie');
|
||||
assertTitle($this->actingAs($admin)->get('/admin?tab=users'), 'Użytkownicy');
|
||||
});
|
||||
@@ -31,16 +31,16 @@ test('every page renders without error against fully seeded data', function () {
|
||||
$this->get('/')->assertOk()->assertSee('Jak możemy pomóc');
|
||||
$this->get('/login')->assertOk()->assertSee('Nowe zgłoszenie bez logowania');
|
||||
|
||||
$this->actingAs($client)->get('/client')->assertOk()->assertSee('Moje zgłoszenia')->assertSee('Panel Klienta');
|
||||
$this->actingAs($client)->get('/client/new')->assertOk()->assertSee('Panel Klienta');
|
||||
$this->actingAs($client)->get(route('client.ticket', $clientTicket))->assertOk()->assertSee($clientTicket->subject)->assertSee('Panel Klienta');
|
||||
$this->actingAs($client)->get('/client')->assertOk()->assertSee('Moje zgłoszenia')->assertSee('Klient');
|
||||
$this->actingAs($client)->get('/client/new')->assertOk()->assertSee('Klient');
|
||||
$this->actingAs($client)->get(route('client.ticket', $clientTicket))->assertOk()->assertSee($clientTicket->subject)->assertSee('Klient');
|
||||
|
||||
$this->actingAs($operator)->get('/operator')->assertOk()->assertSee('Otwarte')->assertSee('Panel Operatora');
|
||||
$this->actingAs($operator)->get('/operator/new')->assertOk()->assertSee($client->email)->assertSee('Panel Operatora');
|
||||
$this->actingAs($operator)->get(route('operator.ticket', $anyTicket))->assertOk()->assertSee($anyTicket->subject)->assertSee('Panel Operatora');
|
||||
$this->actingAs($operator)->get('/operator')->assertOk()->assertSee('Otwarte')->assertSee('Operator');
|
||||
$this->actingAs($operator)->get('/operator/new')->assertOk()->assertSee($client->email)->assertSee('Operator');
|
||||
$this->actingAs($operator)->get(route('operator.ticket', $anyTicket))->assertOk()->assertSee($anyTicket->subject)->assertSee('Operator');
|
||||
|
||||
foreach (['categories', 'fields', 'users', 'teams', 'user-fields', 'reply-quick-actions', 'response-templates', 'statuses', 'priorities', 'templates', 'branding', 'config', 'about'] as $tab) {
|
||||
$this->actingAs($admin)->get('/admin')->assertOk()->assertSee('Panel Administratora');
|
||||
$this->actingAs($admin)->get('/admin')->assertOk()->assertSee('Administrator');
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', $tab)
|
||||
->assertOk();
|
||||
|
||||
80
src/tests/Feature/TicketListPaginationTest.php
Normal file
80
src/tests/Feature/TicketListPaginationTest.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Client\Dashboard;
|
||||
use App\Livewire\Client\TicketShow;
|
||||
use App\Livewire\Operator\Queue;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('the operator queue paginates instead of rendering every matching ticket at once', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('queue-page@example.com');
|
||||
|
||||
foreach (range(1, 55) as $i) {
|
||||
makeTicket(['number' => str_pad($i, 4, '0', STR_PAD_LEFT)]);
|
||||
}
|
||||
|
||||
$component = Livewire::actingAs($operator)->test(Queue::class);
|
||||
|
||||
expect($component->instance()->filteredTickets)->toHaveCount(50)
|
||||
->and($component->instance()->filteredTickets->total())->toBe(55);
|
||||
|
||||
$component->call('gotoPage', 2);
|
||||
|
||||
expect($component->instance()->filteredTickets)->toHaveCount(5);
|
||||
});
|
||||
|
||||
test('changing the queue search resets the operator to page 1', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('queue-page-reset@example.com');
|
||||
|
||||
foreach (range(1, 55) as $i) {
|
||||
makeTicket(['number' => str_pad($i, 4, '0', STR_PAD_LEFT), 'subject' => 'Zgłoszenie']);
|
||||
}
|
||||
makeTicket(['number' => '9999', 'subject' => 'Unikalny temat']);
|
||||
|
||||
$component = Livewire::actingAs($operator)->test(Queue::class)->call('gotoPage', 2);
|
||||
expect($component->instance()->filteredTickets->currentPage())->toBe(2);
|
||||
|
||||
$component->set('search', 'Unikalny');
|
||||
|
||||
expect($component->instance()->filteredTickets->currentPage())->toBe(1)
|
||||
->and($component->instance()->filteredTickets)->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('the client dashboard paginates current/archive tickets separately', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$client = User::query()->create(['name' => 'Client', 'email' => 'dash-page@example.com', 'roles' => ['client']]);
|
||||
|
||||
foreach (range(1, 25) as $i) {
|
||||
makeTicket(['number' => str_pad($i, 4, '0', STR_PAD_LEFT), 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name, 'status_key' => 'new']);
|
||||
}
|
||||
foreach (range(1, 3) as $i) {
|
||||
makeTicket(['number' => '90'.$i, 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name, 'status_key' => 'closed']);
|
||||
}
|
||||
|
||||
$component = Livewire::actingAs($client)->test(Dashboard::class);
|
||||
|
||||
expect($component->instance()->currentTickets)->toHaveCount(20)
|
||||
->and($component->instance()->currentTickets->total())->toBe(25)
|
||||
->and($component->instance()->archiveTickets)->toHaveCount(3);
|
||||
});
|
||||
|
||||
test('"Inne Twoje zgłoszenia" shows only a handful until "Pokaż wszystkie" is clicked', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$client = User::query()->create(['name' => 'Client', 'email' => 'other-tickets@example.com', 'roles' => ['client']]);
|
||||
$viewed = makeTicket(['number' => '5000', 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]);
|
||||
|
||||
foreach (range(1, 8) as $i) {
|
||||
makeTicket(['number' => '60'.$i, 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]);
|
||||
}
|
||||
|
||||
$component = Livewire::actingAs($client)->test(TicketShow::class, ['ticket' => $viewed]);
|
||||
|
||||
expect($component->instance()->otherTickets)->toHaveCount(5)
|
||||
->and($component->instance()->otherTicketsCount)->toBe(8);
|
||||
|
||||
$component->call('revealAllOtherTickets');
|
||||
|
||||
expect($component->instance()->otherTickets)->toHaveCount(8);
|
||||
});
|
||||
Reference in New Issue
Block a user