mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(canvas): let admins drag project zones on the projects canvas
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c410f355e7
commit
7fa3110f0b
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<int, array<string, mixed>>, nodes: array<int, array<string, mixed>>, links: array<int, array<string, mixed>>, 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<int, array<string, mixed>>, nodes: array<int, array<string, mixed>>, links: array<int, array<string, mixed>>, 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.
|
||||
*
|
||||
|
||||
@@ -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 => [
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
$table->json('canvas_position')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
$table->dropColumn('canvas_position');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 @@
|
||||
<div class="absolute top-0 left-0 origin-top-left">
|
||||
<div class="absolute top-0 left-0">
|
||||
{#each boxes as box (box.id ?? '__none')}
|
||||
<Zone {box} movable={canMove} nodes={box.nodes.map(nodeById).filter(Boolean)} usage={usage[box.id]} onpress={(e, header) => startZoneDrag(box, header, e)} />
|
||||
<Zone {box} movable={canMove || canMoveZones} grabBody={canMoveZones} nodes={box.nodes.map(nodeById).filter(Boolean)} usage={usage[box.id]} onpress={(e, header) => startZoneDrag(box, header, e)} />
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Server view: project / environment frames inside the server zones. -->
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script>
|
||||
// A zone frame (server, or a group of the connect view) with its header; the header drags the zone.
|
||||
// grabBody (projects canvas): the whole frame drags the zone, so a press on its empty space does not pan the canvas.
|
||||
// Server canvas zones (zone.details) add a second row: host facts, proxy, live usage and a status summary.
|
||||
import { formatMemory, formatUptime, NO_SERVER, statusSummary, zoneHeader } from '../../environment-canvas.js';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
let { box, movable, onpress, nodes = [], usage = null } = $props();
|
||||
let { box, movable, grabBody = false, onpress, nodes = [], usage = null } = $props();
|
||||
let frame;
|
||||
let header;
|
||||
// The server IP stays hidden until the user asks for it (screen sharing, screenshots).
|
||||
let showIp = $state(false);
|
||||
const zone = $derived(box.zone);
|
||||
const ok = $derived(zone?.is_reachable && zone?.is_usable);
|
||||
const title = $derived(movable ? 'Drag to move every card in this zone' : !zone ? 'Resources without a server' : undefined);
|
||||
const title = $derived(movable ? (grabBody ? 'Drag to move this zone' : 'Drag to move every card in this zone') : !zone ? 'Resources without a server' : undefined);
|
||||
const details = $derived(zone?.details);
|
||||
const host = $derived(details
|
||||
? [details.os, details.arch, details.cpus ? `${details.cpus} ${details.cpus === 1 ? 'CPU' : 'CPUs'}` : null, formatMemory(details.memory_bytes), details.uptime_since && formatUptime(details.uptime_since) ? `up ${formatUptime(details.uptime_since)}` : null].filter(Boolean)
|
||||
@@ -29,14 +31,19 @@
|
||||
const sep = 'h-3 w-px shrink-0 bg-neutral-200 dark:bg-white/[0.1]';
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute rounded-2xl border border-neutral-200 bg-[color-mix(in_oklab,var(--color-neutral-50),white_50%)] dark:border-white/[0.07] dark:bg-[color-mix(in_oklab,var(--color-app),var(--color-panel)_35%)]"
|
||||
bind:this={frame}
|
||||
class="absolute rounded-2xl border border-neutral-200 bg-[color-mix(in_oklab,var(--color-neutral-50),white_50%)] dark:border-white/[0.07] dark:bg-[color-mix(in_oklab,var(--color-app),var(--color-panel)_35%)] {grabBody ? 'cursor-grab active:cursor-grabbing' : ''}"
|
||||
data-ec-zone-drag={grabBody || undefined}
|
||||
title={grabBody ? title : undefined}
|
||||
onpointerdown={grabBody ? e => onpress(e, frame) : undefined}
|
||||
style:left="{box.x}px"
|
||||
style:top="{box.y}px"
|
||||
style:width="{box.width}px"
|
||||
style:height="{box.height}px"
|
||||
>
|
||||
<div bind:this={header} class="px-4 {movable ? 'cursor-grab active:cursor-grabbing' : ''}" style:height="{zoneHeader(zone)}px" data-ec-zone-header {title} role="presentation" onpointerdown={e => onpress(e, header)}>
|
||||
<div bind:this={header} class="px-4 {movable ? 'cursor-grab active:cursor-grabbing' : ''}" style:height="{zoneHeader(zone)}px" data-ec-zone-header title={grabBody ? undefined : title} role="presentation" onpointerdown={grabBody ? undefined : e => onpress(e, header)}>
|
||||
<div class="flex h-10 items-center gap-2 text-[12px]">
|
||||
<Icon name={zone?.icon || 'server'} class="size-3.5 text-neutral-400 dark:text-fg-faint" />
|
||||
<a class="truncate font-semibold text-neutral-700 dark:text-fg [&[href]]:cursor-pointer [&[href]]:underline-offset-2 [&[href]]:hover:underline" href={zone?.link || undefined} draggable="false">{box.name || NO_SERVER}</a>
|
||||
|
||||
@@ -229,90 +229,123 @@ export const groupKey = (nodes, zoneId) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Server view: zones stack downwards. A group with cards saved on its environment canvas (node.saved) sits where
|
||||
* Server view: zones stack downwards; a zone with a saved place (zone.position, projects canvas) sits there and the
|
||||
* stacked zones keep clear of it. A group with cards saved on its environment canvas (node.saved) sits where
|
||||
* those cards are, so the server canvas and the environment canvas share one position (a group that would
|
||||
* leave the zone or cover another group moves to the nearest free spot). Other groups follow below,
|
||||
* GROUPS_PER_ROW per row. When `groups` is given, it gets per group key its frame top-left, the zone's inner
|
||||
* top-left, and `shift`: environment canvas position = server view position - shift.
|
||||
* top-left, and `shift`: environment canvas position = server view position - shift. When `zones` is given,
|
||||
* it gets per zone id the zone's top-left (the place that zone.position saves).
|
||||
*/
|
||||
export function layoutServerView(graph, groups = null, { compact = false } = {}) {
|
||||
export function layoutServerView(graph, groups = null, { compact = false, zones = null } = {}) {
|
||||
const out = {};
|
||||
const laid = zoneGroups(graph).map(zone => ({ zone, ...layoutServerZone(zone, graph.links || [], compact) }));
|
||||
// Saved zones first, so the stacked zones can keep clear of them.
|
||||
const taken = [];
|
||||
const put = (l, origin) => {
|
||||
for (const [id, p] of Object.entries(l.positions)) out[id] = { x: origin.x + p.x, y: origin.y + p.y };
|
||||
for (const [key, g] of Object.entries(groups ? l.groups : {})) {
|
||||
groups[key] = {
|
||||
x: origin.x + g.x,
|
||||
y: origin.y + g.y,
|
||||
inner: { x: origin.x + g.inner.x, y: origin.y + g.inner.y },
|
||||
shift: { x: origin.x + g.shift.x, y: origin.y + g.shift.y },
|
||||
};
|
||||
}
|
||||
if (zones && l.zone.id != null) zones[l.zone.id] = { ...origin };
|
||||
taken.push({ x: origin.x - ZONE_PADDING, y: origin.y, w: l.width + ZONE_PADDING * 2, h: l.height });
|
||||
};
|
||||
const savedPlace = l => (isPoint(l.zone.zone?.position) ? { x: Number(l.zone.zone.position.x), y: Number(l.zone.zone.position.y) } : null);
|
||||
for (const l of laid.filter(savedPlace)) put(l, savedPlace(l));
|
||||
const pinned = taken.slice();
|
||||
let y = 0;
|
||||
for (const zone of zoneGroups(graph)) {
|
||||
const inner = { x: 0, y: y + zoneHeader(zone.zone) + ZONE_PADDING };
|
||||
const blocks = contextGroups(zone.nodes).map(([, nodes]) => {
|
||||
const saved = nodes.filter(n => isPoint(n.saved));
|
||||
const block = layoutBlock(nodes, graph.links || []);
|
||||
return {
|
||||
key: groupKey(nodes, zone.id),
|
||||
nodes,
|
||||
block,
|
||||
size: { w: block.width + GROUP_PADDING * 2, h: GROUP_HEADER + GROUP_PADDING * 2 + block.height },
|
||||
at: saved.length ? { x: Math.min(...saved.map(n => Number(n.saved.x))), y: Math.min(...saved.map(n => Number(n.saved.y))) } : null,
|
||||
};
|
||||
});
|
||||
// Empty environments (placeholder cards only) go next to the other groups after these are placed.
|
||||
const isEmpty = b => b.nodes.every(n => n.type === 'empty');
|
||||
const pinned = blocks.filter(b => b.at).sort((a, b) => a.at.y - b.at.y || a.at.x - b.at.x);
|
||||
// The environment canvas puts its first cards below a zone header; those land at the zone's top-left here.
|
||||
const shift = { x: inner.x + GROUP_PADDING, y: inner.y + GROUP_HEADER + GROUP_PADDING - ZONE_HEADER - ZONE_PADDING };
|
||||
const frames = [];
|
||||
const place = ({ key, block, size }, left, top) => {
|
||||
frames.push({ x: left, y: top });
|
||||
for (const [id, p] of Object.entries(block.positions)) {
|
||||
out[id] = { x: left + GROUP_PADDING + p.x, y: top + GROUP_HEADER + GROUP_PADDING + p.y };
|
||||
}
|
||||
if (groups && key) groups[key] = { x: left, y: top, inner, shift };
|
||||
return top + size.h;
|
||||
};
|
||||
const taken = [];
|
||||
const inside = b => b.x >= inner.x && b.y >= inner.y;
|
||||
let bottom = inner.y;
|
||||
for (const b of pinned) {
|
||||
// Environment canvas positions can be left of or above the zone (negative coordinates): keep the group inside.
|
||||
const wanted = {
|
||||
x: Math.max(inner.x, b.at.x + shift.x - GROUP_PADDING),
|
||||
y: Math.max(inner.y, b.at.y + shift.y - GROUP_HEADER - GROUP_PADDING),
|
||||
...b.size,
|
||||
};
|
||||
const spot = freeSpot(wanted, taken, GROUP_GAP, inside) || wanted;
|
||||
taken.push({ ...spot, project: b.nodes[0]?.project?.uuid });
|
||||
bottom = Math.max(bottom, place(b, spot.x, spot.y));
|
||||
}
|
||||
let x = 0;
|
||||
let rowTop = pinned.length ? bottom + GROUP_GAP : inner.y;
|
||||
let rowBottom = rowTop;
|
||||
blocks.filter(b => !b.at && !isEmpty(b)).forEach((b, i) => {
|
||||
if (i > 0 && i % GROUPS_PER_ROW === 0) {
|
||||
x = 0;
|
||||
rowTop = rowBottom + GROUP_GAP;
|
||||
}
|
||||
taken.push({ x, y: rowTop, ...b.size, project: b.nodes[0]?.project?.uuid });
|
||||
rowBottom = Math.max(rowBottom, place(b, x, rowTop));
|
||||
x += b.size.w + GROUP_GAP;
|
||||
});
|
||||
bottom = Math.max(bottom, rowBottom);
|
||||
// Right of the last group of the same project (or of the zone), at the nearest free spot.
|
||||
for (const b of blocks.filter(b => !b.at && isEmpty(b))) {
|
||||
const project = b.nodes[0]?.project?.uuid;
|
||||
const anchor = taken.filter(t => t.project === project).at(-1) || taken.at(-1);
|
||||
const wanted = anchor ? { x: anchor.x + anchor.w + GROUP_GAP, y: anchor.y, ...b.size } : { ...inner, ...b.size };
|
||||
const spot = freeSpot(wanted, taken, GROUP_GAP, inside) || wanted;
|
||||
taken.push({ ...spot, project });
|
||||
bottom = Math.max(bottom, place(b, spot.x, spot.y));
|
||||
}
|
||||
if (compact && frames.length) {
|
||||
// The groups keep their arrangement, but start at the zone's top-left (no empty space from saved positions).
|
||||
const dx = inner.x - Math.min(...frames.map(f => f.x));
|
||||
const dy = inner.y - Math.min(...frames.map(f => f.y));
|
||||
for (const n of zone.nodes) if (out[n.id]) out[n.id] = { x: out[n.id].x + dx, y: out[n.id].y + dy };
|
||||
bottom += dy;
|
||||
}
|
||||
y = bottom + ZONE_PADDING + ZONE_GAP;
|
||||
for (const l of laid.filter(l => !savedPlace(l))) {
|
||||
const wanted = { x: -ZONE_PADDING, y, w: l.width + ZONE_PADDING * 2, h: l.height };
|
||||
const spot = (pinned.length && freeSpot(wanted, pinned, ZONE_GAP, b => b.x === wanted.x && b.y >= y)) || wanted;
|
||||
put(l, { x: 0, y: spot.y });
|
||||
y = spot.y + l.height + ZONE_GAP;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One zone of the server view laid out at the origin (see layoutServerView). */
|
||||
function layoutServerZone(zone, links, compact) {
|
||||
const out = {};
|
||||
const groups = {};
|
||||
const inner = { x: 0, y: zoneHeader(zone.zone) + ZONE_PADDING };
|
||||
const blocks = contextGroups(zone.nodes).map(([, nodes]) => {
|
||||
const saved = nodes.filter(n => isPoint(n.saved));
|
||||
const block = layoutBlock(nodes, links);
|
||||
return {
|
||||
key: groupKey(nodes, zone.id),
|
||||
nodes,
|
||||
block,
|
||||
size: { w: block.width + GROUP_PADDING * 2, h: GROUP_HEADER + GROUP_PADDING * 2 + block.height },
|
||||
at: saved.length ? { x: Math.min(...saved.map(n => Number(n.saved.x))), y: Math.min(...saved.map(n => Number(n.saved.y))) } : null,
|
||||
};
|
||||
});
|
||||
// Empty environments (placeholder cards only) go next to the other groups after these are placed.
|
||||
const isEmpty = b => b.nodes.every(n => n.type === 'empty');
|
||||
const pinned = blocks.filter(b => b.at).sort((a, b) => a.at.y - b.at.y || a.at.x - b.at.x);
|
||||
// The environment canvas puts its first cards below a zone header; those land at the zone's top-left here.
|
||||
const shift = { x: inner.x + GROUP_PADDING, y: inner.y + GROUP_HEADER + GROUP_PADDING - ZONE_HEADER - ZONE_PADDING };
|
||||
const frames = [];
|
||||
const place = ({ key, block, size }, left, top) => {
|
||||
frames.push({ x: left, y: top, w: size.w });
|
||||
for (const [id, p] of Object.entries(block.positions)) {
|
||||
out[id] = { x: left + GROUP_PADDING + p.x, y: top + GROUP_HEADER + GROUP_PADDING + p.y };
|
||||
}
|
||||
if (key) groups[key] = { x: left, y: top, inner, shift };
|
||||
return top + size.h;
|
||||
};
|
||||
const taken = [];
|
||||
const inside = b => b.x >= inner.x && b.y >= inner.y;
|
||||
let bottom = inner.y;
|
||||
for (const b of pinned) {
|
||||
// Environment canvas positions can be left of or above the zone (negative coordinates): keep the group inside.
|
||||
const wanted = {
|
||||
x: Math.max(inner.x, b.at.x + shift.x - GROUP_PADDING),
|
||||
y: Math.max(inner.y, b.at.y + shift.y - GROUP_HEADER - GROUP_PADDING),
|
||||
...b.size,
|
||||
};
|
||||
const spot = freeSpot(wanted, taken, GROUP_GAP, inside) || wanted;
|
||||
taken.push({ ...spot, project: b.nodes[0]?.project?.uuid });
|
||||
bottom = Math.max(bottom, place(b, spot.x, spot.y));
|
||||
}
|
||||
let x = 0;
|
||||
let rowTop = pinned.length ? bottom + GROUP_GAP : inner.y;
|
||||
let rowBottom = rowTop;
|
||||
blocks.filter(b => !b.at && !isEmpty(b)).forEach((b, i) => {
|
||||
if (i > 0 && i % GROUPS_PER_ROW === 0) {
|
||||
x = 0;
|
||||
rowTop = rowBottom + GROUP_GAP;
|
||||
}
|
||||
taken.push({ x, y: rowTop, ...b.size, project: b.nodes[0]?.project?.uuid });
|
||||
rowBottom = Math.max(rowBottom, place(b, x, rowTop));
|
||||
x += b.size.w + GROUP_GAP;
|
||||
});
|
||||
bottom = Math.max(bottom, rowBottom);
|
||||
// Right of the last group of the same project (or of the zone), at the nearest free spot.
|
||||
for (const b of blocks.filter(b => !b.at && isEmpty(b))) {
|
||||
const project = b.nodes[0]?.project?.uuid;
|
||||
const anchor = taken.filter(t => t.project === project).at(-1) || taken.at(-1);
|
||||
const wanted = anchor ? { x: anchor.x + anchor.w + GROUP_GAP, y: anchor.y, ...b.size } : { ...inner, ...b.size };
|
||||
const spot = freeSpot(wanted, taken, GROUP_GAP, inside) || wanted;
|
||||
taken.push({ ...spot, project });
|
||||
bottom = Math.max(bottom, place(b, spot.x, spot.y));
|
||||
}
|
||||
if (compact && frames.length) {
|
||||
// The groups keep their arrangement, but start at the zone's top-left (no empty space from saved positions).
|
||||
const dx = inner.x - Math.min(...frames.map(f => f.x));
|
||||
const dy = inner.y - Math.min(...frames.map(f => f.y));
|
||||
for (const n of zone.nodes) if (out[n.id]) out[n.id] = { x: out[n.id].x + dx, y: out[n.id].y + dy };
|
||||
for (const f of frames) f.x += dx;
|
||||
bottom += dy;
|
||||
}
|
||||
const width = Math.max(0, ...frames.map(f => f.x + f.w), zone.zone?.details ? SERVER_ZONE_MIN_WIDTH - ZONE_PADDING * 2 : 0);
|
||||
return { positions: out, groups, width, height: bottom + ZONE_PADDING };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared variables view: variable cards in one column on the left (by name). Resources that use a
|
||||
* variable sit next to them in one column, grouped by project / environment, so every link runs
|
||||
|
||||
@@ -668,3 +668,33 @@ test('variables view: saved environment canvas positions do not push resources a
|
||||
assert.equal(resources.y, variables.y, 'both zones start at the top');
|
||||
assert.ok(resources.x - (variables.x + variables.width) <= ZONE_GAP * 2, 'resources sit next to the variables');
|
||||
});
|
||||
|
||||
test('projects canvas: a zone with a saved place sits there, also above the other zones', () => {
|
||||
const g = {
|
||||
layout: 'server',
|
||||
zones: [{ id: 'z1', name: 'Blog' }, { id: 'z2', name: 'Shop', position: { x: 900, y: -400 } }],
|
||||
nodes: [envNode('a', 'application', 'z1', 'prod'), envNode('b', 'application', 'z2', 'prod')],
|
||||
links: [],
|
||||
};
|
||||
const groups = {};
|
||||
const zones = {};
|
||||
const positions = layoutServerView(g, groups, { zones });
|
||||
assert.deepEqual(zones, { z1: { x: 0, y: 0 }, z2: { x: 900, y: -400 } });
|
||||
const box = zoneBoxes(g, positions).find(z => z.id === 'z2');
|
||||
assert.deepEqual({ x: box.x + ZONE_PADDING, y: box.y }, { x: 900, y: -400 });
|
||||
// The group of the moved zone keeps its environment canvas mapping relative to the zone.
|
||||
assert.equal(groups['prod:z2'].inner.y, -400 + ZONE_HEADER + ZONE_PADDING);
|
||||
});
|
||||
|
||||
test('projects canvas: zones without a saved place stack below a saved zone that is in their way', () => {
|
||||
const g = {
|
||||
layout: 'server',
|
||||
zones: [{ id: 'z1', name: 'Blog', position: { x: 0, y: 0 } }, { id: 'z2', name: 'Shop' }, { id: 'z3', name: 'Wiki' }],
|
||||
nodes: [envNode('a', 'application', 'z1', 'prod'), envNode('b', 'application', 'z2', 'prod'), envNode('c', 'application', 'z3', 'prod')],
|
||||
links: [],
|
||||
};
|
||||
const positions = layoutServerView(g);
|
||||
const boxes = Object.fromEntries(zoneBoxes(g, positions).map(z => [z.id, z]));
|
||||
assert.ok(boxes.z2.y >= boxes.z1.y + boxes.z1.height + ZONE_GAP);
|
||||
assert.ok(boxes.z3.y >= boxes.z2.y + boxes.z2.height + ZONE_GAP);
|
||||
});
|
||||
|
||||
@@ -203,6 +203,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('/projects', [ProjectController::class, 'projects'])->middleware(['api.ability:read']);
|
||||
Route::post('/projects', [ProjectController::class, 'create_project'])->middleware(['api.ability:write']);
|
||||
Route::get('/projects/canvas', [ProjectCanvasController::class, 'index'])->middleware(['api.ability:read']);
|
||||
Route::patch('/projects/canvas/positions', [ProjectCanvasController::class, 'updatePositions'])->middleware(['api.ability:write']);
|
||||
Route::get('/projects/{uuid}/canvas', [ProjectCanvasController::class, 'show'])->middleware(['api.ability:read']);
|
||||
Route::get('/projects/{uuid}/environments', [ProjectController::class, 'get_environments'])->middleware(['api.ability:read']);
|
||||
Route::post('/projects/{uuid}/environments', [ProjectController::class, 'create_environment'])->middleware(['api.ability:write']);
|
||||
|
||||
@@ -269,3 +269,61 @@ test('a member cannot create an environment from the project canvas', function (
|
||||
|
||||
expect($this->project->environments()->where('name', 'testing')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('an admin moves project zones on the projects canvas and the canvas returns their place', function () {
|
||||
$blog = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'Blog']);
|
||||
// Another team's project with the same request stays as it is.
|
||||
$foreign = Project::factory()->create(['team_id' => Team::factory()->create()->id]);
|
||||
|
||||
projectCanvasSession($this, $this->admin, $this->team)
|
||||
->getJson('/canvas/api/projects/canvas')
|
||||
->assertJsonPath('can_move_zones', true)
|
||||
->assertJsonPath('zones.0.position', null);
|
||||
|
||||
projectCanvasSession($this, $this->admin, $this->team)
|
||||
->patchJson('/canvas/api/projects/canvas/positions', ['positions' => [
|
||||
$this->project->uuid => ['x' => 640, 'y' => -120],
|
||||
$blog->uuid => ['x' => 0, 'y' => 0],
|
||||
$foreign->uuid => ['x' => 5, 'y' => 5],
|
||||
]])
|
||||
->assertOk()
|
||||
->assertJsonPath("positions.{$this->project->uuid}", ['x' => 640, 'y' => -120])
|
||||
->assertJsonMissingPath("positions.{$foreign->uuid}");
|
||||
|
||||
expect($foreign->fresh()->canvas_position)->toBeNull();
|
||||
// Zones are sorted by name: Blog, Shop.
|
||||
projectCanvasSession($this, $this->admin, $this->team)
|
||||
->getJson('/canvas/api/projects/canvas')
|
||||
->assertJsonPath('zones.0.position', ['x' => 0, 'y' => 0])
|
||||
->assertJsonPath('zones.1.position', ['x' => 640, 'y' => -120]);
|
||||
});
|
||||
|
||||
test('project zone positions must be whole coordinates on the canvas', function () {
|
||||
projectCanvasSession($this, $this->admin, $this->team)
|
||||
->patchJson('/canvas/api/projects/canvas/positions', ['positions' => [$this->project->uuid => ['x' => 'left', 'y' => 999999999]]])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(["positions.{$this->project->uuid}.x", "positions.{$this->project->uuid}.y"]);
|
||||
|
||||
expect($this->project->fresh()->canvas_position)->toBeNull();
|
||||
});
|
||||
|
||||
test('members and other teams cannot move a project zone', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
projectCanvasSession($this, $member, $this->team)
|
||||
->getJson('/canvas/api/projects/canvas')
|
||||
->assertJsonPath('can_move_zones', false);
|
||||
projectCanvasSession($this, $member, $this->team)
|
||||
->patchJson('/canvas/api/projects/canvas/positions', ['positions' => [$this->project->uuid => ['x' => 10, 'y' => 10]]])
|
||||
->assertForbidden();
|
||||
|
||||
// Admin of this team, owner of another team, with the session switched to the other team.
|
||||
$otherTeam = Team::factory()->create(['show_boarding' => false]);
|
||||
$otherTeam->members()->attach($this->admin->id, ['role' => 'owner']);
|
||||
projectCanvasSession($this, $this->admin, $otherTeam)
|
||||
->patchJson('/canvas/api/projects/canvas/positions', ['positions' => [$this->project->uuid => ['x' => 10, 'y' => 10]]])
|
||||
->assertOk()
|
||||
->assertJsonPath('positions', []);
|
||||
|
||||
expect($this->project->fresh()->canvas_position)->toBeNull();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user