added countries support expanded for ISO 3166 standard merged in new support

This commit is contained in:
Mark Gregory
2026-06-15 11:00:14 +01:00
parent 4099be7770
commit 96e0d60355
10 changed files with 187 additions and 65 deletions
-25
View File
@@ -1,25 +0,0 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Countries\Http\Controllers\Api\CountriesApiController;
use Modules\Countries\Http\Controllers\Api\ReferenceDataApiController;
Route::prefix('api/v1/countries')->name('api.countries.')->group(function (): void {
Route::get('/', [CountriesApiController::class, 'index'])->name('index');
Route::get('/active', [CountriesApiController::class, 'active'])->name('active');
Route::get('/resolve/{code}', [CountriesApiController::class, 'resolve'])
->where('code', '[A-Za-z0-9]{2,3}')
->name('resolve');
Route::get('/{country:alpha2}', [CountriesApiController::class, 'show'])->name('show');
Route::get('/{alpha2}/subdivisions', [CountriesApiController::class, 'subdivisions'])
->where('alpha2', '[A-Za-z]{2}')
->name('subdivisions');
});
Route::prefix('api/v1')->name('api.reference.')->group(function (): void {
Route::get('/currencies', [ReferenceDataApiController::class, 'currencies'])->name('currencies.index');
Route::get('/languages', [ReferenceDataApiController::class, 'languages'])->name('languages.index');
Route::get('/scripts', [ReferenceDataApiController::class, 'scripts'])->name('scripts.index');
Route::get('/locodes', [ReferenceDataApiController::class, 'locodes'])->name('locodes.index');
});
@@ -14,6 +14,17 @@ class DocumentationModuleTest extends TestCase
{
use RefreshDatabase;
/**
* Add a CSRF token for projects that keep web middleware enabled during tests.
*
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function csrfPayload(array $payload): array
{
return array_merge(['_token' => 'test-token'], $payload);
}
/**
* The documentation dashboard renders for authenticated users.
*/
@@ -40,7 +51,7 @@ class DocumentationModuleTest extends TestCase
'is_active' => true,
]);
$response = $this->actingAs($user)->post(route('documentation.documents.store'), [
$response = $this->actingAs($user)->withSession(['_token' => 'test-token'])->post(route('documentation.documents.store'), $this->csrfPayload([
'documentation_category_id' => $category->id,
'title' => 'Goods In Process',
'slug' => 'goods-in-process',
@@ -50,7 +61,7 @@ class DocumentationModuleTest extends TestCase
'version' => '1.0.0',
'reference' => 'SOP-001',
'is_featured' => '1',
]);
]));
$response->assertRedirect();
$this->assertDatabaseHas('documentation_documents', [
@@ -72,7 +83,7 @@ class DocumentationModuleTest extends TestCase
$user = \App\Models\User::factory()->create();
$file = UploadedFile::fake()->createWithContent('goods-in.md', "# Goods In\n\n- Scan delivery note\n- Confirm quantities");
$response = $this->actingAs($user)->post(route('documentation.documents.store'), [
$response = $this->actingAs($user)->withSession(['_token' => 'test-token'])->post(route('documentation.documents.store'), $this->csrfPayload([
'title' => 'Goods In Markdown',
'slug' => 'goods-in-markdown',
'summary' => 'Markdown controlled document.',
@@ -80,7 +91,7 @@ class DocumentationModuleTest extends TestCase
'status' => DocumentationDocument::STATUS_PUBLISHED,
'version' => '1.0.0',
'attachment' => $file,
]);
]));
$response->assertRedirect();
@@ -102,7 +113,7 @@ class DocumentationModuleTest extends TestCase
$user = \App\Models\User::factory()->create();
$response = $this->actingAs($user)->post(route('documentation.documents.store'), [
$response = $this->actingAs($user)->withSession(['_token' => 'test-token'])->post(route('documentation.documents.store'), $this->csrfPayload([
'title' => 'Attachment Pack',
'slug' => 'attachment-pack',
'summary' => 'Document with supporting files.',
@@ -113,7 +124,7 @@ class DocumentationModuleTest extends TestCase
UploadedFile::fake()->createWithContent('setup.md', "```php\necho 'ok';\n```"),
UploadedFile::fake()->create('process.pdf', 64, 'application/pdf'),
],
]);
]));
$response->assertRedirect();
@@ -129,7 +140,8 @@ class DocumentationModuleTest extends TestCase
->assertOk();
$this->actingAs($user)
->delete(route('documentation.documents.attachments.destroy', [$document, $attachment]))
->withSession(['_token' => 'test-token'])
->delete(route('documentation.documents.attachments.destroy', [$document, $attachment]), ['_token' => 'test-token'])
->assertRedirect(route('documentation.documents.edit', $document));
$this->assertDatabaseMissing('documentation_attachments', ['id' => $attachment->id]);
@@ -158,14 +170,14 @@ class DocumentationModuleTest extends TestCase
{
$user = \App\Models\User::factory()->create();
$this->actingAs($user)->post(route('documentation.categories.store'), [
$this->actingAs($user)->withSession(['_token' => 'test-token'])->post(route('documentation.categories.store'), $this->csrfPayload([
'name' => 'Policies',
'slug' => 'policies',
'colour' => 'emerald',
'description' => 'Controlled business policies.',
'sort_order' => 1,
'is_active' => '1',
])->assertRedirect(route('documentation.categories.index'));
]))->assertRedirect(route('documentation.categories.index'));
$this->assertDatabaseHas('documentation_categories', [
'name' => 'Policies',
@@ -1,9 +0,0 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Countries\Http\Controllers\Api\CountryLookupController;
if (class_exists(CountryLookupController::class)) {
Route::get('/countries', [CountryLookupController::class, 'index'])->name('api.countries.index');
Route::get('/countries/{country:alpha2}', [CountryLookupController::class, 'show'])->name('api.countries.show');
}
+6 -5
View File
@@ -59,6 +59,7 @@ final class TicketIndex extends Component
private function tickets(): LengthAwarePaginator
{
$user = Auth::user();
$search = trim($this->search);
return Ticket::query()
->with(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team'])
@@ -67,11 +68,11 @@ final class TicketIndex extends Component
->when($this->scope === 'closed', static fn (Builder $query): Builder => $query->whereNotNull('completed_at'))
->when($this->status !== '', fn (Builder $query): Builder => $query->where('status_id', (int) $this->status))
->when($this->priority !== '', fn (Builder $query): Builder => $query->where('priority_id', (int) $this->priority))
->when($this->search !== '', function (Builder $query): Builder {
return $query->where(static function (Builder $subQuery): void {
$subQuery->where('reference', 'like', '%'.$this->search.'%')
->orWhere('subject', 'like', '%'.$this->search.'%')
->orWhere('body', 'like', '%'.$this->search.'%');
->when($search !== '', static function (Builder $query) use ($search): Builder {
return $query->where(static function (Builder $subQuery) use ($search): void {
$subQuery->where('reference', 'like', '%'.$search.'%')
->orWhere('subject', 'like', '%'.$search.'%')
->orWhere('body', 'like', '%'.$search.'%');
});
})
->latest('last_activity_at')
+1 -2
View File
@@ -29,8 +29,7 @@ final class TicketPolicy
public function update(User $user, Ticket $ticket): bool
{
return $this->isPrivileged($user)
|| (int) $ticket->agent_id === (int) $user->id
|| (int) $ticket->user_id === (int) $user->id;
|| ((bool) ($user->helpdesk_agent ?? false) && (int) $ticket->agent_id === (int) $user->id);
}
public function comment(User $user, Ticket $ticket): bool
@@ -25,7 +25,7 @@
@include('helpdesk::partials.page-header', [
'eyebrow' => 'Helpdesk',
'title' => 'Create Ticket',
'title' => 'Create ticket',
'subtitle' => 'Raise a new support request with priority, project, category, team and full details.',
])
@@ -17,6 +17,7 @@ final class AgentAssignmentService
public function selectAgentId(?int $categoryId): ?int
{
$query = User::query()
->select('users.*')
->where(static function (Builder $query): void {
$query->where('helpdesk_agent', true)->orWhere('helpdesk_admin', true);
})
@@ -11,6 +11,15 @@
]"
/>
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
@foreach ([['Companies', $companiesCount], ['Active', $activeCompaniesCount], ['Warehouses', $warehousesCount], ['Prices', $pricesCount], ['Licences', $licensesCount]] as [$label, $value])
<article class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p class="text-sm text-zinc-500 dark:text-zinc-400">{{ $label }}</p>
<p class="mt-2 text-3xl font-bold text-zinc-900 dark:text-white">{{ $value }}</p>
</article>
@endforeach
</section>
<section class="grid gap-5 xl:grid-cols-3">
<article class="rounded-3xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div class="mb-5 flex items-start justify-between gap-4">
@@ -79,6 +88,81 @@
</article>
</section>
<section class="rounded-3xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div class="mb-5 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-xs font-bold uppercase tracking-[0.28em] text-cyan-500">Pricing</p>
<h2 class="mt-2 text-xl font-bold text-zinc-900 dark:text-white">
Pricing plans
</h2>
<p class="text-sm text-zinc-500 dark:text-zinc-400">
Current platform pricing tiers and administration shortcuts.
</p>
</div>
<div class="flex flex-wrap gap-2">
<a
href="{{ route('platform.pricing.index') }}"
wire:navigate
class="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-zinc-200 px-4 text-sm font-bold text-zinc-700 hover:border-cyan-400 hover:text-cyan-700 dark:border-zinc-700 dark:text-zinc-200"
>
<i class="fa-solid fa-credit-card" aria-hidden="true"></i>
View pricing
</a>
<a
href="{{ route('platform.prices.index') }}"
wire:navigate
class="inline-flex h-10 items-center justify-center gap-2 rounded-xl bg-cyan-600 px-4 text-sm font-bold text-white shadow-sm shadow-cyan-600/20 ring-1 ring-cyan-500/30 transition hover:bg-cyan-500"
>
<i class="fa-solid fa-tags" aria-hidden="true"></i>
Manage prices
</a>
</div>
</div>
<div class="grid gap-4 md:grid-cols-3">
@foreach ($prices as $price)
<article class="rounded-2xl border border-zinc-200 p-5 dark:border-zinc-800">
<div class="flex items-start justify-between gap-3">
<div>
<h3 class="text-lg font-bold text-zinc-900 dark:text-white">
{{ $price->name }}
</h3>
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
{{ $price->strapline }}
</p>
</div>
@if ($price->badge_label)
<span class="rounded-full bg-cyan-500/10 px-3 py-1 text-xs font-bold uppercase tracking-wide text-cyan-600 dark:text-cyan-400">
{{ $price->badge_label }}
</span>
@endif
</div>
<p class="mt-4 text-2xl font-black text-zinc-900 dark:text-white">
£{{ number_format($price->monthly_amount / 100, 0) }}
<span class="text-sm font-semibold text-zinc-500">/ month</span>
</p>
@if (! empty($price->features))
<ul class="mt-4 space-y-2 text-sm text-zinc-600 dark:text-zinc-300">
@foreach (array_slice($price->features, 0, 3) as $feature)
<li class="flex gap-2">
<i class="fa-solid fa-check mt-1 text-cyan-500" aria-hidden="true"></i>
<span>{{ $feature }}</span>
</li>
@endforeach
</ul>
@endif
</article>
@endforeach
</div>
</section>
<section class="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
<article class="rounded-3xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
beStrictAboutTestsThatDoNotTestAnything="false"
colors="true"
processIsolation="false"
stopOnError="false"
stopOnFailure="false"
cacheDirectory=".phpunit.cache"
backupStaticProperties="false">
<testsuites>
<testsuite name="Browser Test Suite">
<directory suffix="Test.php">./tests/Browser</directory>
</testsuite>
</testsuites>
</phpunit>
+58 -14
View File
@@ -5,12 +5,14 @@ namespace Tests;
use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Artisan;
use Laravel\Dusk\TestCase as BaseTestCase;
use PHPUnit\Framework\Attributes\BeforeClass;
abstract class DuskTestCase extends BaseTestCase
{
use CreatesApplication;
/**
* Prepare for Dusk test execution.
*/
@@ -18,6 +20,7 @@ abstract class DuskTestCase extends BaseTestCase
public static function prepare(): void
{
if (! static::runningInSail()) {
static::ensureChromeDriverBinaryAlias();
static::startChromeDriver(['--port=9515']);
}
}
@@ -27,22 +30,63 @@ abstract class DuskTestCase extends BaseTestCase
*/
protected function driver(): RemoteWebDriver
{
$options = (new ChromeOptions)->addArguments(collect([
$this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080',
$options = (new ChromeOptions())->addArguments([
'--disable-gpu',
'--headless=new',
'--window-size=1920,1080',
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-search-engine-choice-screen',
'--disable-smooth-scrolling',
])->unless($this->hasHeadlessDisabled(), function (Collection $items) {
return $items->merge([
'--disable-gpu',
'--headless=new',
]);
})->all());
]);
return RemoteWebDriver::create(
$_ENV['DUSK_DRIVER_URL'] ?? env('DUSK_DRIVER_URL') ?? 'http://localhost:9515',
DesiredCapabilities::chrome()->setCapability(
ChromeOptions::CAPABILITY, $options
)
$_ENV['DUSK_DRIVER_URL'] ?? 'http://localhost:9515',
DesiredCapabilities::chrome()->setCapability(ChromeOptions::CAPABILITY, $options)
);
}
/**
* Dusk may install a differently named macOS Apple Silicon binary.
* Laravel Dusk still asks for chromedriver-mac-arm, so create that alias safely.
*/
private static function ensureChromeDriverBinaryAlias(): void
{
$binDirectory = base_path('vendor/laravel/dusk/bin');
$expected = $binDirectory.'/chromedriver-mac-arm';
if (is_file($expected)) {
@chmod($expected, 0755);
return;
}
if (! is_dir($binDirectory)) {
@mkdir($binDirectory, 0755, true);
}
try {
Artisan::call('dusk:chrome-driver');
} catch (\Throwable) {
// Continue and inspect whatever files are available locally.
}
$candidates = array_values(array_filter(glob($binDirectory.'/chromedriver*') ?: [], static function (string $path) use ($expected): bool {
return is_file($path)
&& $path !== $expected
&& ! str_ends_with($path, '.zip')
&& ! str_ends_with($path, '.txt');
}));
usort($candidates, static function (string $left, string $right): int {
$score = static fn (string $path): int => str_contains($path, 'mac-arm64') ? 0
: (str_contains($path, 'mac-arm') ? 1
: (str_contains($path, 'mac') ? 2 : 3));
return $score($left) <=> $score($right);
});
if ($candidates !== []) {
@copy($candidates[0], $expected);
@chmod($expected, 0755);
}
}
}