fix(project): show production, staging, and development environments first

Closes #12317

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-25 23:47:32 +01:00
co-authored by Claude Opus 5.5
parent 35e1a05705
commit 0f0a1e920d
7 changed files with 91 additions and 8 deletions
+1 -1
View File
@@ -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,
+12
View File
@@ -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');
+9 -3
View File
@@ -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);
}
+17 -2
View File
@@ -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, []);
+12
View File
@@ -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));
@@ -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() {
@@ -0,0 +1,30 @@
<?php
use App\Livewire\Project\Show as ProjectShow;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('project page lists production, staging, and development before the other environments', function () {
Queue::fake();
InstanceSettings::unguarded(fn () => 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']);
});