diff --git a/app/Http/Controllers/Api/ProjectCanvasController.php b/app/Http/Controllers/Api/ProjectCanvasController.php index 534cd7f56a..7dd5e02b63 100644 --- a/app/Http/Controllers/Api/ProjectCanvasController.php +++ b/app/Http/Controllers/Api/ProjectCanvasController.php @@ -4,16 +4,20 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Project; +use App\Services\EnvironmentCanvas; use App\Services\ProjectCanvas; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Validator; /** * Project canvas: the resources of every environment of one project (or of every project). - * Moves and links are saved through EnvironmentCanvasController. Called by resources/js/environment-canvas.js through the /canvas/api session bridge. + * Moves of cards and links are saved through EnvironmentCanvasController; the projects canvas saves the place of a project here. Called by resources/js/environment-canvas.js through the /canvas/api session bridge. */ class ProjectCanvasController extends Controller { + private const MAX_POSITIONS = 1000; + public function __construct(private ProjectCanvas $canvas) {} public function index(Request $request): JsonResponse @@ -41,6 +45,50 @@ class ProjectCanvasController extends Controller return response()->json($this->canvas->payload($project, (bool) $request->attributes->get('can_read_sensitive', false), $this->canEdit($request, (int) $project->team_id))); } + /** + * Saves the place of project zones on the projects canvas (top-left of each zone), keyed by project UUID. + */ + public function updatePositions(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + $coordinateRule = ['required', 'integer', 'between:-'.EnvironmentCanvas::MAX_COORDINATE.','.EnvironmentCanvas::MAX_COORDINATE]; + $validator = Validator::make($request->all(), [ + 'positions' => ['required', 'array', 'max:'.self::MAX_POSITIONS], + 'positions.*' => ['required', 'array'], + 'positions.*.x' => $coordinateRule, + 'positions.*.y' => $coordinateRule, + ]); + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + // Projects of other teams are left out, like unknown UUIDs. + $projects = Project::query()->whereTeamId($teamId)->whereIn('uuid', array_keys($request->input('positions')))->get(); + foreach ($projects as $project) { + $this->authorize('update', $project); + } + + $saved = []; + foreach ($projects as $project) { + $position = $request->input("positions.{$project->uuid}"); + $project->canvas_position = $saved[$project->uuid] = ['x' => (int) $position['x'], 'y' => (int) $position['y']]; + $project->save(); + } + + return response()->json(['positions' => (object) $saved]); + } + /** * Same rule as the environment canvas: team admins with a write token move cards and draw links. */ diff --git a/app/Models/Project.php b/app/Models/Project.php index 7df5ecad50..a32adf2a51 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -32,6 +32,15 @@ class Project extends BaseModel 'uuid', ]; + protected array $auditExclude = ['canvas_position']; + + protected function casts(): array + { + return [ + 'canvas_position' => 'array', + ]; + } + /** * Get query builder for projects owned by current team. * If you need all projects without further query chaining, use ownedByCurrentTeamCached() instead. diff --git a/app/Services/ProjectCanvas.php b/app/Services/ProjectCanvas.php index 57619b2e30..602e23dbe5 100644 --- a/app/Services/ProjectCanvas.php +++ b/app/Services/ProjectCanvas.php @@ -20,11 +20,11 @@ class ProjectCanvas /** * The canvas of every project of a team: one zone per project, cards grouped by environment. * - * @return array{title: string, title_link: string, can_edit: bool, can_move: false, can_move_groups: bool, layout: 'server', zones: array>, nodes: array>, links: array>, positions: array{}} + * @return array{title: string, title_link: string, can_edit: bool, can_move: false, can_move_groups: bool, can_move_zones: bool, layout: 'server', zones: array>, nodes: array>, links: array>, positions: array{}} */ public function teamPayload(int $teamId, bool $canReadSensitive, bool $canEdit = false): array { - $projects = Project::query()->where('team_id', $teamId)->orderBy('name')->get(['id', 'uuid', 'name', 'team_id']); + $projects = Project::query()->where('team_id', $teamId)->orderBy('name')->get(['id', 'uuid', 'name', 'team_id', 'canvas_position']); $environments = Environment::query()->whereIn('project_id', $projects->pluck('id')) ->orderBy('id') ->get(['id', 'uuid', 'name', 'project_id']); @@ -48,6 +48,8 @@ class ProjectCanvas 'can_move' => false, // Admins move groups and cards here; the canvas saves them on their environment canvas. 'can_move_groups' => $canEdit, + // Admins move the project zones; each project saves its place (zone.position). + 'can_move_zones' => $canEdit, // Same grouped layout as the server canvas, with projects as the zones. 'layout' => 'server', 'zones' => $projects->map(fn (Project $project) => [ @@ -55,6 +57,7 @@ class ProjectCanvas 'name' => $project->name, 'icon' => 'projects', 'link' => route('project.show', ['project_uuid' => $project->uuid]), + 'position' => $this->position($project->canvas_position), ])->values()->all(), 'nodes' => collect($graph['nodes'])->map(fn (array $node) => [...$node, ...$grouping->get($node['id'])]) ->concat($projects->flatMap(fn (Project $project) => $this->emptyNodes($project, $environments->where('project_id', $project->id), $resources, $project->uuid, $canEdit))) @@ -121,6 +124,18 @@ class ProjectCanvas ->values(); } + /** + * A saved project zone position, or null when the zone is auto placed. + * + * @return array{x: int, y: int}|null + */ + private function position(mixed $saved): ?array + { + return is_array($saved) && is_numeric($saved['x'] ?? null) && is_numeric($saved['y'] ?? null) + ? ['x' => (int) $saved['x'], 'y' => (int) $saved['y']] + : null; + } + /** * Project and environment of a card: the canvas saves links on that environment canvas. * diff --git a/app/Services/ServerTransfer/ServerTransferCoverage.php b/app/Services/ServerTransfer/ServerTransferCoverage.php index 1c615e304d..c37bf61382 100644 --- a/app/Services/ServerTransfer/ServerTransferCoverage.php +++ b/app/Services/ServerTransfer/ServerTransferCoverage.php @@ -221,6 +221,7 @@ final class ServerTransferCoverage 'icon_path' => 'The icon file is exported as `icon` (base64 content). The target stores it in its own icon storage and sets the path.', 'icon_storage_type' => 'The target uses its own default icon storage. See icon_path.', 'icon_s3_storage_id' => 'The target uses its own default icon storage. See icon_path.', + 'canvas_position' => 'Place of the project on the projects canvas of the source; the target places it with its own projects.', ], Environment::class => ['project_id' => self::LINKED], ResourceLink::class => [ diff --git a/database/migrations/2026_10_23_182201_add_canvas_position_to_projects_table.php b/database/migrations/2026_10_23_182201_add_canvas_position_to_projects_table.php new file mode 100644 index 0000000000..e7642e0821 --- /dev/null +++ b/database/migrations/2026_10_23_182201_add_canvas_position_to_projects_table.php @@ -0,0 +1,22 @@ +json('canvas_position')->nullable(); + }); + } + + public function down(): void + { + Schema::table('projects', function (Blueprint $table) { + $table->dropColumn('canvas_position'); + }); + } +}; diff --git a/resources/js/canvas/environment/EnvironmentCanvas.svelte b/resources/js/canvas/environment/EnvironmentCanvas.svelte index f5f70d76ad..d6690c9632 100644 --- a/resources/js/canvas/environment/EnvironmentCanvas.svelte +++ b/resources/js/canvas/environment/EnvironmentCanvas.svelte @@ -6,7 +6,7 @@ // The server canvas (data-endpoint) reuses this view. It saves moves and links on the environment canvas of each card. // The shared variables and tags canvases (data-mode) reuse it too: their links add a variable or a tag to a resource. import { flushSync, onMount, tick } from 'svelte'; - import { api, BOX_GAP, freeSpot, panZoom } from '../../canvas-core.js'; + import { api, apart, BOX_GAP, freeSpot, panZoom } from '../../canvas-core.js'; import { canConnect, canLink, @@ -31,8 +31,10 @@ readView, STATUS_CHANNELS, unreachableZones, + ZONE_PADDING, zoneBoxes, zoneGroups, + zoneHeader, } from '../../environment-canvas.js'; import Card from './Card.svelte'; import Deployments from './Deployments.svelte'; @@ -89,7 +91,7 @@ const unsaved = {}; // Saved moves stay on the canvas until a load that started after the save returns them, so an older // response (a poll or status refresh during the drag or the request) cannot put the cards back for a moment. - const settling = { positions: {}, saved: {} }; // id -> { at, after: load number the save finished at } + const settling = { positions: {}, saved: {}, zones: {} }; // id -> { at, after: load number the save finished at } let loads = 0; let reloadQueued = false; let fitted = false; @@ -118,6 +120,9 @@ /** Server view: key of the card's group when the card can be moved there (it is saved on its environment canvas). */ const movableGroupOf = node => (canMoveGroups && node ? groupKey([node], node.zone) : null); let groupMeta = {}; // server view: frame top-left and zone inner top-left per group key (see layoutServerView) + // Projects canvas: admins drag the project zones; each project saves the zone's top-left (zone.position). + const canMoveZones = $derived(canEdit && !!graph?.can_move_zones); + let zoneOrigins = {}; // server view: top-left per zone id (see layoutServerView) // Environment canvas endpoints of a card (the server view has no endpoints of its own for changes). const environmentBase = node => (serverView ? `/projects/${encodeURIComponent(node?.project?.uuid || '')}/environments/${encodeURIComponent(node?.environment?.uuid || '')}/canvas` @@ -315,7 +320,7 @@ clearTimeout(timers.view); timers.view = setTimeout(saveView, 300); }, - canPan: e => !e.target.closest('[data-ec-card],[data-ec-zone-header],[data-ec-group-header],[data-ec-label],[data-ec-link],a,button,input'), + canPan: e => !e.target.closest('[data-ec-card],[data-ec-zone-header],[data-ec-zone-drag],[data-ec-group-header],[data-ec-label],[data-ec-link],a,button,input'), onPanEnd: moved => { if (!moved) closePanel(); }, @@ -510,7 +515,7 @@ } function startZoneDrag(box, header, e) { - if (e.button !== 0 || !canMove || e.target.closest('a')) return; + if (e.button !== 0 || !(canMove || canMoveZones) || e.target.closest('a,button')) return; e.stopPropagation(); const ids = box.nodes.filter(id => positions[id]); const starts = Object.fromEntries(ids.map(id => [id, { ...positions[id] }])); @@ -518,23 +523,45 @@ // Zones cannot cover each other: the frame stops at the edge of another zone. const zone = toBox(box); const others = zoneBoxes(graph, positions, sizes).filter(z => z.id !== box.id).map(toBox); - let moved = false; + let moved = null; // { x, y }: how far the zone moved capture(header, e.pointerId); begin('zone'); track(header, ev => { if (!moved && Math.hypot(ev.clientX - origin.cx, ev.clientY - origin.cy) < 4) return; const spot = freeSpot({ ...zone, x: zone.x + (ev.clientX - origin.cx) / view.z, y: zone.y + (ev.clientY - origin.cy) / view.z }, others); if (!spot) return; - moved = true; - const dx = spot.x - zone.x; - const dy = spot.y - zone.y; - for (const id of ids) positions[id] = { x: starts[id].x + dx, y: starts[id].y + dy }; + moved = { x: spot.x - zone.x, y: spot.y - zone.y }; + for (const id of ids) positions[id] = { x: starts[id].x + moved.x, y: starts[id].y + moved.y }; }, () => { - if (moved) queueSave(Object.fromEntries(ids.map(id => [id, { ...positions[id] }]))); + if (moved && canMove) queueSave(Object.fromEntries(ids.map(id => [id, { ...positions[id] }]))); + else if (moved) saveZones(box.id, moved); end('zone'); }); } + /** + * Projects canvas: saves the place of a moved zone. Zones that are still placed automatically are saved where they + * are too, so they do not move into the space the zone left. + */ + async function saveZones(id, delta) { + const saved = Object.fromEntries(Object.entries(zoneOrigins) + .filter(([zoneId]) => zoneId === id || !isPlaced(full.zones.find(z => z.id === zoneId)?.position)) + .map(([zoneId, at]) => [zoneId, zoneId === id ? { x: Math.round(at.x + delta.x), y: Math.round(at.y + delta.y) } : at])); + hold(settling.zones, saved); + try { + await api('PATCH', `${base}/positions`, { positions: saved }); + settle(settling.zones, saved); + } catch (e) { + release(settling.zones, saved); + if (destroyed) return; + toast(e.message, true); + load({ quiet: true }); + return; + } + if (!destroyed) applyGraph(full); + } + const isPlaced = p => !!p && Number.isFinite(p.x) && Number.isFinite(p.y); + /** Server view: drag a project / environment group inside its server zone; it cannot cover another group. */ function startGroupDrag(frame, header, e) { if (e.button !== 0 || !canMoveGroups || !frame.key || !groupMeta[frame.key] || e.target.closest('a')) return; @@ -544,7 +571,18 @@ const starts = Object.fromEntries(ids.map(id => [id, { ...positions[id] }])); const box = toBox(frame); const others = frames.filter(f => f !== frame && f.zone === frame.zone).map(toBox); - const fits = b => b.x >= meta.inner.x && b.y >= meta.inner.y; + // Projects canvas: zones can sit anywhere, so the group's zone must not grow over another zone. + const otherZones = canMoveZones ? boxes.filter(z => z.id !== frame.zone).map(toBox) : []; + const top = ZONE_PADDING + zoneHeader(boxes.find(z => z.id === frame.zone)?.zone); + const zoneAround = b => { + const all = [...others, b]; + const x = Math.min(...all.map(f => f.x)); + const y = Math.min(...all.map(f => f.y)); + const w = Math.max(...all.map(f => f.x + f.w)) - x; + const h = Math.max(...all.map(f => f.y + f.h)) - y; + return { x: x - ZONE_PADDING, y: y - top, w: w + ZONE_PADDING * 2, h: h + ZONE_PADDING + top }; + }; + const fits = b => b.x >= meta.inner.x && b.y >= meta.inner.y && otherZones.every(z => apart(zoneAround(b), z, BOX_GAP)); const origin = { cx: e.clientX, cy: e.clientY }; let moved = false; capture(header, e.pointerId); @@ -722,10 +760,12 @@ const kept = Object.fromEntries(Object.entries(settling.positions).map(([id, s]) => [id, s.at])); next.positions = { ...next.positions, ...kept, ...Object.fromEntries(Object.entries(unsaved).map(([id, p]) => [id, { x: p.x, y: p.y }])) }; if (Object.keys(settling.saved).length) next.nodes = next.nodes.map(n => (settling.saved[n.id] ? { ...n, saved: settling.saved[n.id].at } : n)); + if (Object.keys(settling.zones).length) next.zones = next.zones.map(z => (settling.zones[z.id] ? { ...z, position: settling.zones[z.id].at } : z)); full = next; const shown = groupByUse(filterGraph(next, filter)); groupMeta = {}; - positions = shown.layout === 'server' ? layoutServerView(shown, groupMeta) : layoutGraph(shown); + zoneOrigins = {}; + positions = shown.layout === 'server' ? layoutServerView(shown, groupMeta, { zones: zoneOrigins }) : layoutGraph(shown); graph = shown; error = null; if (hover && !nodeById(hover)) hover = null; @@ -910,7 +950,7 @@
{#each boxes as box (box.id ?? '__none')} - startZoneDrag(box, header, e)} /> + startZoneDrag(box, header, e)} /> {/each}
diff --git a/resources/js/canvas/environment/Zone.svelte b/resources/js/canvas/environment/Zone.svelte index a4f65a3b7e..d2b51c1bce 100644 --- a/resources/js/canvas/environment/Zone.svelte +++ b/resources/js/canvas/environment/Zone.svelte @@ -1,16 +1,18 @@ +
onpress(e, frame) : undefined} style:left="{box.x}px" style:top="{box.y}px" style:width="{box.width}px" style:height="{box.height}px" > -