mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-09 19:31:57 +02:00
feat(import): group the workspace's environments under one heading
An imported OpenAPI document brings a base environment plus one per server, and they landed as loose rows at the top of the tree with no way to take them as a set. They can't nest under anything real — a sub-environment is a sibling of the base one, not its child — so a heading groups them and its checkbox turns the set on and off. The tree's rows are now either a plan item or a heading, which is what the destination workspace has always been: it no longer has to pose as a plan item with an invented action and selection. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0dbefd4283
commit
33378741af
@@ -6,7 +6,7 @@ import {
|
|||||||
type ImportSource,
|
type ImportSource,
|
||||||
type Workspace,
|
type Workspace,
|
||||||
} from "@yaakapp-internal/models";
|
} from "@yaakapp-internal/models";
|
||||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
import { HStack, Icon, type IconProps, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { formatDistanceToNowStrict } from "date-fns";
|
import { formatDistanceToNowStrict } from "date-fns";
|
||||||
@@ -263,15 +263,11 @@ function LoadedImportDataDialog({
|
|||||||
// A folder row's checkbox carries everything beneath it, deletions included — the row labels
|
// A folder row's checkbox carries everything beneath it, deletions included — the row labels
|
||||||
// say which of those are destructive. Checking anything also brings back the folders it needs
|
// say which of those are destructive. Checking anything also brings back the folders it needs
|
||||||
// to live in.
|
// to live in.
|
||||||
const toggleNode = (node: CheckboxTreeNode<ImportPlanItem>, checked: boolean) => {
|
const toggleNode = (node: CheckboxTreeNode<TreeRow>, checked: boolean) => {
|
||||||
const targets = new Set(
|
const targets = new Set(togglableItems(node).map((i) => i.modelId));
|
||||||
collectItems(node)
|
if (checked && node.data.kind === "item") {
|
||||||
.filter(isTogglable)
|
|
||||||
.map((i) => i.modelId),
|
|
||||||
);
|
|
||||||
if (checked) {
|
|
||||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||||
for (const ancestor of ancestorsOf(node.data, byId)) {
|
for (const ancestor of ancestorsOf(node.data.item, byId)) {
|
||||||
if (isMissingFolder(ancestor)) targets.add(ancestor.modelId);
|
if (isMissingFolder(ancestor)) targets.add(ancestor.modelId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,7 +319,7 @@ function LoadedImportDataDialog({
|
|||||||
|
|
||||||
// The destination workspace roots the tree. It is not a plan item — commit always applies
|
// The destination workspace roots the tree. It is not a plan item — commit always applies
|
||||||
// it — so its checkbox only aggregates the subtree.
|
// it — so its checkbox only aggregates the subtree.
|
||||||
const workspaceRoot: CheckboxTreeNode<ImportPlanItem> = (() => {
|
const workspaceRoot: CheckboxTreeNode<TreeRow> = (() => {
|
||||||
const planned = plan.resources.workspaces[0];
|
const planned = plan.resources.workspaces[0];
|
||||||
const planDestination = plan.destination;
|
const planDestination = plan.destination;
|
||||||
const existing =
|
const existing =
|
||||||
@@ -333,12 +329,8 @@ function LoadedImportDataDialog({
|
|||||||
return {
|
return {
|
||||||
key: existing?.id ?? planned?.id ?? "workspace",
|
key: existing?.id ?? planned?.id ?? "workspace",
|
||||||
data: {
|
data: {
|
||||||
action: plan.destination.type === "new_workspace" ? "create" : "unchanged",
|
kind: "destination",
|
||||||
model: "workspace",
|
label: existing?.name ?? planned?.name ?? "New workspace",
|
||||||
modelId: existing?.id ?? planned?.id ?? "workspace",
|
|
||||||
name: existing?.name ?? planned?.name ?? "New workspace",
|
|
||||||
selected: true,
|
|
||||||
changedFields: [],
|
|
||||||
},
|
},
|
||||||
children: itemTree,
|
children: itemTree,
|
||||||
};
|
};
|
||||||
@@ -357,9 +349,12 @@ function LoadedImportDataDialog({
|
|||||||
checked={nodeCheckedStatus}
|
checked={nodeCheckedStatus}
|
||||||
onCheck={toggleNode}
|
onCheck={toggleNode}
|
||||||
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
|
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
|
||||||
isCollapsedByDefault={(n) => n.data.action === "ignored"}
|
isCollapsedByDefault={(n) => n.data.kind === "item" && n.data.item.action === "ignored"}
|
||||||
isRelevant={(n) => n.data.model === "workspace" || n.data.action !== "unchanged"}
|
isRelevant={(n) =>
|
||||||
renderRow={(n) => <ImportTreeRow item={n.data} onResolveConflict={resolveConflict} />}
|
n.data.kind === "destination" ||
|
||||||
|
(n.data.kind === "item" && n.data.item.action !== "unchanged")
|
||||||
|
}
|
||||||
|
renderRow={(n) => <ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -545,21 +540,26 @@ function LoadedImportDataDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ImportTreeRow({
|
function ImportTreeRow({
|
||||||
item,
|
row,
|
||||||
onResolveConflict,
|
onResolveConflict,
|
||||||
}: {
|
}: {
|
||||||
item: ImportPlanItem;
|
row: TreeRow;
|
||||||
onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void;
|
onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void;
|
||||||
}) {
|
}) {
|
||||||
|
if (row.kind !== "item") {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{item.model === "workspace" || item.model === "folder" || item.model === "environment" ? (
|
<Icon color="secondary" icon={row.kind === "destination" ? "house" : row.icon} />
|
||||||
<Icon
|
<div className="truncate flex-1">{row.label}</div>
|
||||||
color="secondary"
|
</>
|
||||||
icon={
|
);
|
||||||
item.model === "workspace" ? "house" : item.model === "folder" ? "folder" : "variable"
|
|
||||||
}
|
}
|
||||||
/>
|
|
||||||
|
const { item } = row;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{item.model === "folder" || item.model === "environment" ? (
|
||||||
|
<Icon color="secondary" icon={item.model === "folder" ? "folder" : "variable"} />
|
||||||
) : (
|
) : (
|
||||||
<span aria-hidden className="w-4" />
|
<span aria-hidden className="w-4" />
|
||||||
)}
|
)}
|
||||||
@@ -663,7 +663,17 @@ function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): I
|
|||||||
return ancestors;
|
return ancestors;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<ImportPlanItem>[] {
|
/**
|
||||||
|
* A row of the preview tree. Most are plan items, but the destination workspace and the group the
|
||||||
|
* workspace's environments sit in are headings: they aggregate their children and decide nothing
|
||||||
|
* themselves.
|
||||||
|
*/
|
||||||
|
type TreeRow =
|
||||||
|
| { kind: "destination"; label: string }
|
||||||
|
| { kind: "group"; label: string; icon: IconProps["icon"] }
|
||||||
|
| { kind: "item"; item: ImportPlanItem };
|
||||||
|
|
||||||
|
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
|
||||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||||
const childrenOf = new Map<string, ImportPlanItem[]>();
|
const childrenOf = new Map<string, ImportPlanItem[]>();
|
||||||
const roots: ImportPlanItem[] = [];
|
const roots: ImportPlanItem[] = [];
|
||||||
@@ -683,9 +693,9 @@ function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<ImportPlanItem
|
|||||||
...list.filter((i) => i.model !== "environment" && i.model !== "folder"),
|
...list.filter((i) => i.model !== "environment" && i.model !== "folder"),
|
||||||
];
|
];
|
||||||
|
|
||||||
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<ImportPlanItem> => ({
|
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<TreeRow> => ({
|
||||||
key: item.modelId,
|
key: item.modelId,
|
||||||
data: item,
|
data: { kind: "item", item },
|
||||||
children: seen.has(item.modelId)
|
children: seen.has(item.modelId)
|
||||||
? []
|
? []
|
||||||
: byKind(childrenOf.get(item.modelId) ?? []).map((c) =>
|
: byKind(childrenOf.get(item.modelId) ?? []).map((c) =>
|
||||||
@@ -693,11 +703,24 @@ function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<ImportPlanItem
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
return byKind(roots).map((r) => toNode(r, new Set()));
|
// The workspace's environments have nothing to sit under — a sub-environment is a sibling of
|
||||||
|
// the base one, not its child — so a heading groups them into one thing to turn on and off.
|
||||||
|
const environments = roots.filter((i) => i.model === "environment");
|
||||||
|
const others = byKind(roots.filter((i) => i.model !== "environment"));
|
||||||
|
const nodes = others.map((r) => toNode(r, new Set()));
|
||||||
|
if (environments.length === 0) return nodes;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "group:environments",
|
||||||
|
data: { kind: "group", label: "Variables", icon: "variable" },
|
||||||
|
children: environments.map((e) => toNode(e, new Set())),
|
||||||
|
},
|
||||||
|
...nodes,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectItems(node: CheckboxTreeNode<ImportPlanItem>): ImportPlanItem[] {
|
function collectRows(node: CheckboxTreeNode<TreeRow>): TreeRow[] {
|
||||||
return [node.data, ...node.children.flatMap(collectItems)];
|
return [node.data, ...node.children.flatMap(collectRows)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A folder that isn't there yet, so anything inside it needs it brought in first. */
|
/** A folder that isn't there yet, so anything inside it needs it brought in first. */
|
||||||
@@ -706,18 +729,24 @@ function isMissingFolder(item: ImportPlanItem): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a row's checkbox is the decision for it, and so whether a parent's checkbox carries it.
|
* The plan item a row's checkbox decides, if it decides one. An unchanged resource has nothing to
|
||||||
* An unchanged resource has nothing to decide, a conflict is decided by its own control, and the
|
* decide and a conflict is decided by its own control, so neither takes a checkbox — nor rides
|
||||||
* destination workspace is not a plan item.
|
* along with a parent's.
|
||||||
*/
|
*/
|
||||||
function isTogglable(item: ImportPlanItem): boolean {
|
function togglableItem(row: TreeRow): ImportPlanItem | null {
|
||||||
return item.model !== "workspace" && item.action !== "unchanged" && item.action !== "conflict";
|
if (row.kind !== "item") return null;
|
||||||
|
const { item } = row;
|
||||||
|
return item.action === "unchanged" || item.action === "conflict" ? null : item;
|
||||||
}
|
}
|
||||||
|
|
||||||
function nodeCheckedStatus(
|
function togglableItems(node: CheckboxTreeNode<TreeRow>): ImportPlanItem[] {
|
||||||
node: CheckboxTreeNode<ImportPlanItem>,
|
return collectRows(node)
|
||||||
): boolean | "indeterminate" | "hidden" {
|
.map(togglableItem)
|
||||||
const covered = collectItems(node).filter(isTogglable);
|
.filter((i) => i != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeCheckedStatus(node: CheckboxTreeNode<TreeRow>): boolean | "indeterminate" | "hidden" {
|
||||||
|
const covered = togglableItems(node);
|
||||||
if (covered.length === 0) return "hidden";
|
if (covered.length === 0) return "hidden";
|
||||||
const selected = covered.filter((i) => i.selected).length;
|
const selected = covered.filter((i) => i.selected).length;
|
||||||
if (selected === covered.length) return true;
|
if (selected === covered.length) return true;
|
||||||
|
|||||||
Reference in New Issue
Block a user