diff --git a/app/Livewire/Project/Show.php b/app/Livewire/Project/Show.php index a055904d51..a2f2c42918 100644 --- a/app/Livewire/Project/Show.php +++ b/app/Livewire/Project/Show.php @@ -64,7 +64,7 @@ class Show extends Component $canCreateResource = auth()->user()->can('createAnyResource'); return view('livewire.project.show', [ - 'environmentsJs' => $this->project->environments->map(function (Environment $environment) use ($canCreateResource, $canUpdateProject): array { + 'environmentsJs' => $this->project->environments->sort(fn (Environment $first, Environment $second): int => Environment::compareNames($first->name, $second->name))->values()->map(function (Environment $environment) use ($canCreateResource, $canUpdateProject): array { $resourceCount = collect([ $environment->applications_count, $environment->services_count, diff --git a/app/Models/Environment.php b/app/Models/Environment.php index 5521bd31c5..8e11fb72f8 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -56,6 +56,18 @@ class Environment extends BaseModel }); } + /** + * Sort order of environment names: production, staging, and development first, then the others by name. + * resources/js/environment-order.js has the same order for the canvases. + */ + public static function compareNames(string $first, string $second): int + { + $order = ['production', 'staging', 'development']; + $rank = fn (string $name): int => ($index = array_search(strtolower($name), $order, true)) === false ? count($order) : $index; + + return $rank($first) <=> $rank($second) ?: strnatcasecmp($first, $second); + } + public static function ownedByCurrentTeam() { return Environment::whereRelation('project.team', 'id', currentTeam()->id)->orderBy('name'); diff --git a/resources/js/environment-canvas.js b/resources/js/environment-canvas.js index 3d3472d222..a792bc17b2 100644 --- a/resources/js/environment-canvas.js +++ b/resources/js/environment-canvas.js @@ -4,6 +4,7 @@ // DOM-free: `node --test` imports this file. import { BOX_GAP, freeSpot, MAX_ZOOM, MIN_ZOOM } from './canvas-core.js'; +import { compareEnvironmentNames } from './environment-order.js'; export const CARD_WIDTH = 260; export const COLUMN_GAP = 48; @@ -130,7 +131,12 @@ const contextGroups = nodes => { } // Server view: the unmanaged containers group comes first, so it is not lost below many projects. const unmanaged = ([, members]) => (members[0]?.type === 'container' ? 0 : 1); - return [...groups.entries()].sort((a, b) => unmanaged(a) - unmanaged(b) || a[0].localeCompare(b[0])); + // Then by project, then production, staging, and development before the other environments. + const project = ([name, members]) => members[0]?.project?.name ?? name; + const environment = ([name, members]) => members[0]?.environment?.name ?? name; + return [...groups.entries()].sort( + (a, b) => unmanaged(a) - unmanaged(b) || project(a).localeCompare(project(b)) || compareEnvironmentNames(environment(a), environment(b)), + ); }; const byTypeAndName = (a, b) => columnOf(a) - columnOf(b) || String(a.name).localeCompare(String(b.name)); @@ -637,7 +643,7 @@ export function statusLabel(status) { } /** - * Connect and server views: projects of the resources, each with its environments, sorted by name. + * Connect and server views: projects of the resources sorted by name, each with its environments in environment order. * @returns {{uuid: string, name: string, environments: {uuid: string, name: string}[]}[]} */ export function filterOptions(graph) { @@ -649,7 +655,7 @@ export function filterOptions(graph) { } const byName = (a, b) => String(a.name).localeCompare(String(b.name)); return [...projects.values()] - .map(p => ({ uuid: p.uuid, name: p.name, environments: [...p.environments.values()].sort(byName) })) + .map(p => ({ uuid: p.uuid, name: p.name, environments: [...p.environments.values()].sort((a, b) => compareEnvironmentNames(a.name, b.name)) })) .sort(byName); } diff --git a/resources/js/environment-canvas.test.js b/resources/js/environment-canvas.test.js index 0d2bc1753b..740ec6aa6f 100644 --- a/resources/js/environment-canvas.test.js +++ b/resources/js/environment-canvas.test.js @@ -258,6 +258,20 @@ test('server view: one frame per project / environment, and the frames do not ov assert.equal(positions.a.y, positions.b.y); }); +test('groups: production, staging, and development come before the other environments of a project', () => { + const env = (id, project, environment) => + srv(id, 'application', `${project} / ${environment}`, { project: { uuid: project, name: project }, environment: { uuid: id, name: environment } }); + const graph = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [env('a', 'Shop', 'deprecated'), env('b', 'Shop', 'development'), env('c', 'Shop', 'production'), env('d', 'Shop', 'qa'), env('e', 'Shop', 'staging'), env('f', 'Blog', 'zeta')], + links: [], + positions: {}, + }; + const frames = groupFrames(graph.nodes, layoutGraph(graph)); + assert.deepEqual(frames.map(f => f.name), ['Blog / zeta', 'Shop / production', 'Shop / staging', 'Shop / development', 'Shop / deprecated', 'Shop / qa']); +}); + const envNode = (id, type, zone, environment, extra = {}) => srv(id, type, `Shop / ${environment}`, { zone, project: { uuid: 'p' }, environment: { uuid: environment }, ...extra }); test('server view: cards link only to cards of the same environment', () => { @@ -498,15 +512,16 @@ test('variables view: filter resources by project and environment, keep every va node('variable:1', 'variable', 'sources'), node('a', 'application', 'resources', { project: shop, environment: { uuid: 'e1', name: 'production' } }), node('b', 'application', 'resources', { project: shop, environment: { uuid: 'e2', name: 'staging' } }), + node('d', 'application', 'resources', { project: shop, environment: { uuid: 'e4', name: 'deprecated' } }), node('c', 'application', 'resources', { project: blog, environment: { uuid: 'e3', name: 'production' } }), ], links: [{ id: 'l1', source: 'variable:1', target: 'a' }, { id: 'l2', source: 'variable:1', target: 'c' }], }; - assert.deepEqual(filterOptions(g).map(p => [p.name, p.environments.map(e => e.name)]), [['Blog', ['production']], ['Shop', ['production', 'staging']]]); + assert.deepEqual(filterOptions(g).map(p => [p.name, p.environments.map(e => e.name)]), [['Blog', ['production']], ['Shop', ['production', 'staging', 'deprecated']]]); assert.equal(filterGraph(g, {}), g); const shopOnly = filterGraph(g, { project: 'p1' }); - assert.deepEqual(shopOnly.nodes.map(n => n.id), ['variable:1', 'a', 'b']); + assert.deepEqual(shopOnly.nodes.map(n => n.id), ['variable:1', 'a', 'b', 'd']); assert.deepEqual(shopOnly.links.map(l => l.id), ['l1']); assert.deepEqual(filterGraph(g, { environment: 'e2' }).nodes.map(n => n.id), ['variable:1', 'b']); assert.deepEqual(filterGraph(g, { environment: 'e2' }).links, []); diff --git a/resources/js/environment-order.js b/resources/js/environment-order.js new file mode 100644 index 0000000000..b88fb1dffb --- /dev/null +++ b/resources/js/environment-order.js @@ -0,0 +1,12 @@ +const ORDER = ['production', 'staging', 'development']; + +const rank = name => { + const index = ORDER.indexOf(String(name).toLowerCase()); + return index === -1 ? ORDER.length : index; +}; + +/** + * Sort order of environment names: production, staging, and development first, then the others by name. + * Environment::compareNames() has the same order for the project page. + */ +export const compareEnvironmentNames = (a, b) => rank(a) - rank(b) || String(a).localeCompare(String(b)); diff --git a/resources/views/livewire/project/show.blade.php b/resources/views/livewire/project/show.blade.php index 6c74bc3b30..16f4017611 100644 --- a/resources/views/livewire/project/show.blade.php +++ b/resources/views/livewire/project/show.blade.php @@ -211,13 +211,17 @@ function projectEnvironments() { return { search: '', - sortBy: 'name-asc', + sortBy: 'default', sortOpen: false, viewMode: @js($this->listView('project', 'grid')), page: 1, pageSize: 12, environments: @js($environmentsJs), sortOptions: [{ + value: 'default', + label: 'Default' + }, + { value: 'name-asc', label: 'Name A–Z' }, @@ -249,8 +253,12 @@ return second.resourceCount - first.resourceCount || first.name.localeCompare(second.name); } + if (this.sortBy === 'name-asc') { + return first.name.localeCompare(second.name); + } - return first.name.localeCompare(second.name); + // Default: the server order (production, staging, development, then by name). + return 0; }); }, get totalPages() { diff --git a/tests/Feature/ProjectEnvironmentOrderTest.php b/tests/Feature/ProjectEnvironmentOrderTest.php new file mode 100644 index 0000000000..f3df1476ab --- /dev/null +++ b/tests/Feature/ProjectEnvironmentOrderTest.php @@ -0,0 +1,30 @@ + InstanceSettings::query()->create(['id' => 0])); + $user = User::factory()->create(); + $team = Team::factory()->create(); + $user->teams()->attach($team, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + + $project = Project::factory()->create(['team_id' => $team->id]); + foreach (['deprecated', 'staging', 'qa', 'development', 'demo'] as $name) { + $project->environments()->create(['name' => $name]); + } + + Livewire::test(ProjectShow::class, ['project_uuid' => $project->uuid]) + ->assertViewHas('environmentsJs', fn ($environments) => collect($environments)->pluck('name')->all() === ['production', 'staging', 'development', 'demo', 'deprecated', 'qa']); +});