mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-09 19:31:57 +02:00
feat(import): warn that a document already has a workspace
Renaming the second workspace was the mechanism, not the news: what matters is that this document already produced a workspace, so importing it here copies it instead of updating it. The plan says that outright, names the workspace, and does it on key overlap rather than on the name — so it holds after either side has been renamed, and stays quiet when the import is merging into that very workspace. Plan notes carry a level so the ones worth a second thought read as cautions, and the destination row gets its "new" chip back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cdc034b25a
commit
213458b60c
@@ -335,6 +335,7 @@ function LoadedImportDataDialog({
|
||||
data: {
|
||||
kind: "destination",
|
||||
label: existing?.name ?? planned?.name ?? "New workspace",
|
||||
isNew: planDestination.type === "new_workspace",
|
||||
},
|
||||
children: itemTree,
|
||||
};
|
||||
@@ -371,7 +372,12 @@ function LoadedImportDataDialog({
|
||||
key={`${warning.title}:${warning.detail}`}
|
||||
className="flex items-start gap-2.5 px-3 py-2.5"
|
||||
>
|
||||
<Icon icon="info" color="info" size="sm" className="mt-0.5" />
|
||||
<Icon
|
||||
icon={warning.level === "warning" ? "alert_triangle" : "info"}
|
||||
color={warning.level === "warning" ? "warning" : "info"}
|
||||
size="sm"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{warning.title}</div>
|
||||
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
|
||||
@@ -555,11 +561,15 @@ function ImportTreeRow({
|
||||
<>
|
||||
<Icon color="secondary" icon={row.kind === "destination" ? "house" : row.icon} />
|
||||
<div className="truncate flex-1">{row.label}</div>
|
||||
{row.kind === "destination" && row.isNew && (
|
||||
<ActionChip label="new" help="Created by this import" className="text-success" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const { item } = row;
|
||||
const label = actionLabel(item);
|
||||
return (
|
||||
<>
|
||||
{item.model === "folder" || item.model === "environment" ? (
|
||||
@@ -585,27 +595,47 @@ function ImportTreeRow({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
actionLabel(item) && (
|
||||
<InlineCode
|
||||
label != null && (
|
||||
<ActionChip
|
||||
label={label}
|
||||
help={actionHelp(item)}
|
||||
className={classNames(
|
||||
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
|
||||
"inline-flex items-center justify-center gap-1.5",
|
||||
item.action === "create" && "text-success",
|
||||
item.action === "update" && "text-info",
|
||||
item.action === "delete" && "text-danger",
|
||||
item.action === "keep_local" && item.selected && "text-warning",
|
||||
item.action === "ignored" && "text-text-subtlest",
|
||||
)}
|
||||
>
|
||||
{actionLabel(item)}
|
||||
<IconTooltip content={actionHelp(item)} iconSize="xs" />
|
||||
</InlineCode>
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionChip({
|
||||
label,
|
||||
help,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
help: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
|
||||
"inline-flex items-center justify-center gap-1.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{help != null && <IconTooltip content={help} iconSize="xs" />}
|
||||
</InlineCode>
|
||||
);
|
||||
}
|
||||
|
||||
function actionLabel(item: ImportPlanItem): string | null {
|
||||
switch (item.action) {
|
||||
case "create":
|
||||
@@ -673,7 +703,7 @@ function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): I
|
||||
* themselves.
|
||||
*/
|
||||
type TreeRow =
|
||||
| { kind: "destination"; label: string }
|
||||
| { kind: "destination"; label: string; isNew: boolean }
|
||||
| { kind: "group"; label: string; icon: IconProps["icon"] }
|
||||
| { kind: "item"; item: ImportPlanItem };
|
||||
|
||||
|
||||
+6
-1
@@ -88,7 +88,12 @@ export type ImportPlanItem = {
|
||||
*/
|
||||
export type ImportPlanReason = "moved_into_ignored_folder";
|
||||
|
||||
export type ImportPlanWarning = { title: string; detail: string };
|
||||
export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
|
||||
|
||||
/**
|
||||
* Whether a plan's note is something to know or something to think twice about.
|
||||
*/
|
||||
export type ImportPlanWarningLevel = "info" | "warning";
|
||||
|
||||
/**
|
||||
* The model types an import plan can contain.
|
||||
|
||||
Generated
+6
-1
@@ -88,7 +88,12 @@ export type ImportPlanItem = {
|
||||
*/
|
||||
export type ImportPlanReason = "moved_into_ignored_folder";
|
||||
|
||||
export type ImportPlanWarning = { title: string; detail: string };
|
||||
export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
|
||||
|
||||
/**
|
||||
* Whether a plan's note is something to know or something to think twice about.
|
||||
*/
|
||||
export type ImportPlanWarningLevel = "info" | "warning";
|
||||
|
||||
/**
|
||||
* The model types an import plan can contain.
|
||||
|
||||
@@ -109,6 +109,36 @@ pub enum ImportDestination {
|
||||
pub struct ImportPlanWarning {
|
||||
pub title: String,
|
||||
pub detail: String,
|
||||
#[serde(default)]
|
||||
pub level: ImportPlanWarningLevel,
|
||||
}
|
||||
|
||||
/// Whether a plan's note is something to know or something to think twice about.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_util.ts")]
|
||||
pub enum ImportPlanWarningLevel {
|
||||
#[default]
|
||||
Info,
|
||||
Warning,
|
||||
}
|
||||
|
||||
impl ImportPlanWarning {
|
||||
pub fn info(title: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
detail: detail.into(),
|
||||
level: ImportPlanWarningLevel::Info,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn warning(title: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
detail: detail.into(),
|
||||
level: ImportPlanWarningLevel::Warning,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an import's contents came from, used to link the committed workspace back to it.
|
||||
|
||||
+90
-33
@@ -104,13 +104,7 @@ pub fn plan_import_resources(
|
||||
.collect::<Vec<_>>();
|
||||
for workspace in &mut workspaces {
|
||||
let unique = unique_name(&workspace.name, &taken);
|
||||
if unique != workspace.name {
|
||||
warnings.push(ImportPlanWarning {
|
||||
title: "Workspace renamed".to_string(),
|
||||
detail: format!("{} → {unique} · that name is taken", workspace.name),
|
||||
});
|
||||
workspace.name = unique.clone();
|
||||
}
|
||||
taken.push(unique);
|
||||
}
|
||||
|
||||
@@ -135,10 +129,10 @@ pub fn plan_import_resources(
|
||||
} else {
|
||||
format!("{} imported workspaces", resources.workspaces.len())
|
||||
};
|
||||
warnings.push(ImportPlanWarning {
|
||||
title: "Workspace settings skipped".to_string(),
|
||||
detail: format!("{source} · {}", display_list(&skipped_fields)),
|
||||
});
|
||||
warnings.push(ImportPlanWarning::info(
|
||||
"Workspace settings skipped",
|
||||
format!("{source} · {}", display_list(&skipped_fields)),
|
||||
));
|
||||
}
|
||||
}
|
||||
(workspace_id.clone(), folder_id.clone())
|
||||
@@ -265,22 +259,22 @@ pub fn plan_import_resources(
|
||||
|
||||
for (source_name, imported_name, variable_count) in separated_base_environments {
|
||||
let variables = if variable_count == 1 { "variable" } else { "variables" };
|
||||
warnings.push(ImportPlanWarning {
|
||||
title: "Base environment kept separate".to_string(),
|
||||
detail: format!("{source_name} → {imported_name} · {variable_count} {variables}"),
|
||||
});
|
||||
warnings.push(ImportPlanWarning::info(
|
||||
"Base environment kept separate",
|
||||
format!("{source_name} → {imported_name} · {variable_count} {variables}"),
|
||||
));
|
||||
}
|
||||
if converted_duplicate_base_environment {
|
||||
warnings.push(ImportPlanWarning {
|
||||
title: "Base environments separated".to_string(),
|
||||
detail: "Only the first remains the base environment".to_string(),
|
||||
});
|
||||
warnings.push(ImportPlanWarning::info(
|
||||
"Base environments separated",
|
||||
"Only the first remains the base environment",
|
||||
));
|
||||
}
|
||||
if converted_duplicate_folder_environment {
|
||||
warnings.push(ImportPlanWarning {
|
||||
title: "Folder environments separated".to_string(),
|
||||
detail: "Only the first remains attached to each folder".to_string(),
|
||||
});
|
||||
warnings.push(ImportPlanWarning::info(
|
||||
"Folder environments separated",
|
||||
"Only the first remains attached to each folder",
|
||||
));
|
||||
}
|
||||
|
||||
let resources = BatchUpsertResult {
|
||||
@@ -302,9 +296,47 @@ pub fn plan_import_resources(
|
||||
origin,
|
||||
};
|
||||
merge_with_linked_source(query_manager, &mut plan, &original)?;
|
||||
warn_if_imported_elsewhere(&query_manager.connect(), &mut plan)?;
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
/// A document that already produced a workspace can be merged back into it. Importing it anywhere
|
||||
/// else copies it instead, which is worth saying before the user finds two of everything.
|
||||
fn warn_if_imported_elsewhere(db: &ClientDb, plan: &mut ImportPlan) -> Result<()> {
|
||||
let destination_id = match &plan.destination {
|
||||
ImportDestination::ExistingWorkspace { workspace_id, .. } => Some(workspace_id.as_str()),
|
||||
ImportDestination::NewWorkspace => None,
|
||||
};
|
||||
let incoming = plan.source_keys.values().collect::<BTreeSet<_>>();
|
||||
|
||||
let mut names = BTreeSet::new();
|
||||
for workspace in db.list_workspaces()? {
|
||||
if Some(workspace.id.as_str()) == destination_id {
|
||||
continue;
|
||||
}
|
||||
for source in db.list_import_sources(&workspace.id)? {
|
||||
let overlaps = db
|
||||
.list_import_source_resources(&source.id)?
|
||||
.iter()
|
||||
.any(|row| incoming.contains(&row.source_key));
|
||||
if overlaps {
|
||||
names.insert(workspace.name.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if names.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let names = names.iter().map(String::as_str).collect::<BTreeSet<_>>();
|
||||
plan.warnings.push(ImportPlanWarning::warning(
|
||||
"Already imported",
|
||||
format!("{} · importing here makes a second copy", display_list(&names)),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commit a previously prepared plan in one transaction, applying only its selected items.
|
||||
pub fn commit_import_plan(
|
||||
query_manager: &QueryManager,
|
||||
@@ -654,10 +686,10 @@ fn resolve_linked_source(
|
||||
|
||||
fn ambiguous_source_warning(sources: &[ImportSource]) -> ImportPlanWarning {
|
||||
let labels = sources.iter().map(|s| s.origin_label.as_str()).collect::<BTreeSet<_>>();
|
||||
ImportPlanWarning {
|
||||
title: "Imported as new".to_string(),
|
||||
detail: format!("{} already contain these resources", display_list(&labels)),
|
||||
}
|
||||
ImportPlanWarning::warning(
|
||||
"Imported as new",
|
||||
format!("{} already contain these resources", display_list(&labels)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Rewrite a plan against the destination's linked import source, if it has one: resources whose
|
||||
@@ -1671,7 +1703,6 @@ mod tests {
|
||||
)
|
||||
.expect("plan first import");
|
||||
assert_eq!(first.resources.workspaces[0].name, "Imported");
|
||||
assert!(first.warnings.iter().all(|w| w.title != "Workspace renamed"));
|
||||
commit_import_plan(&query_manager, first).expect("commit first import");
|
||||
|
||||
let second = plan_import_resources(
|
||||
@@ -1684,12 +1715,6 @@ mod tests {
|
||||
)
|
||||
.expect("plan second import");
|
||||
assert_eq!(second.resources.workspaces[0].name, "Imported (2)");
|
||||
let warning = second
|
||||
.warnings
|
||||
.iter()
|
||||
.find(|w| w.title == "Workspace renamed")
|
||||
.expect("the rename is explained");
|
||||
assert_eq!(warning.detail, "Imported → Imported (2) · that name is taken");
|
||||
commit_import_plan(&query_manager, second).expect("commit second import");
|
||||
|
||||
let third = plan_import_resources(
|
||||
@@ -1714,6 +1739,38 @@ mod tests {
|
||||
assert_eq!(names, BTreeSet::from(["Imported".to_string(), "Imported (2)".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importing_a_document_that_already_has_a_workspace_warns() {
|
||||
let (query_manager, _blob_manager, _rx) =
|
||||
yaak_models::init_in_memory().expect("initialize database");
|
||||
let committed = first_import(&query_manager);
|
||||
let workspace_id = committed.workspaces[0].id.clone();
|
||||
|
||||
let elsewhere = plan_import_resources(
|
||||
&query_manager,
|
||||
"OpenAPI".to_string(),
|
||||
ImportDestination::NewWorkspace,
|
||||
imported_resources(),
|
||||
Some(importer_keys()),
|
||||
Some(linked_origin()),
|
||||
)
|
||||
.expect("plan a second copy");
|
||||
let warning = elsewhere
|
||||
.warnings
|
||||
.iter()
|
||||
.find(|w| w.title == "Already imported")
|
||||
.expect("copying a document that already landed somewhere is called out");
|
||||
assert_eq!(warning.detail, "Imported · importing here makes a second copy");
|
||||
|
||||
// Merging back into the workspace it created is the whole point, so it says nothing.
|
||||
let merging = replan(&query_manager, &workspace_id, imported_resources());
|
||||
assert!(
|
||||
merging.warnings.iter().all(|w| w.title != "Already imported"),
|
||||
"{:?}",
|
||||
merging.warnings
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_collisions_are_explicit_and_do_not_overwrite() {
|
||||
let (query_manager, _blob_manager, _rx) =
|
||||
|
||||
Reference in New Issue
Block a user