Skip to content
41 changes: 9 additions & 32 deletions apps/obsidian/src/components/RelationshipTypeSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { getContrastColor } from "~/utils/colorUtils";
import {
getImportInfo,
formatImportSource,
getRelationTypeErrors,
isCompleteRelationType,
isProvisionalSchema,
} from "~/utils/typeUtils";
import ImportedSchemaMeta from "./ImportedSchemaMeta";
Expand All @@ -25,7 +27,11 @@ type ColorPickerProps = {
disabled?: boolean;
};

const ColorPicker = ({ value, onChange, disabled }: ColorPickerProps) => {
export const ColorPicker = ({
value,
onChange,
disabled,
}: ColorPickerProps) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -114,37 +120,8 @@ const RelationshipTypeSettings = () => {
>;

const saveSettings = (updatedRelationTypes: DiscourseRelationType[]) => {
const newErrors: Record<number, string> = {};

// Validate only complete types (ones with all required fields)
const completeTypes = updatedRelationTypes.filter(
(rt) => rt.id && rt.label && rt.complement,
);

// Check for duplicate labels
const seenLabels = new Map<string, number>();
for (const rt of completeTypes) {
const idx = updatedRelationTypes.indexOf(rt);
const prev = seenLabels.get(rt.label);
if (prev !== undefined) {
newErrors[idx] = `Duplicate label "${rt.label}"`;
if (!newErrors[prev]) newErrors[prev] = `Duplicate label "${rt.label}"`;
}
seenLabels.set(rt.label, idx);
}

// Check for duplicate complements
const seenComplements = new Map<string, number>();
for (const rt of completeTypes) {
const idx = updatedRelationTypes.indexOf(rt);
const prev = seenComplements.get(rt.complement);
if (prev !== undefined) {
newErrors[idx] = `Duplicate complement "${rt.complement}"`;
if (!newErrors[prev])
newErrors[prev] = `Duplicate complement "${rt.complement}"`;
}
seenComplements.set(rt.complement, idx);
}
const newErrors = getRelationTypeErrors(updatedRelationTypes);
const completeTypes = updatedRelationTypes.filter(isCompleteRelationType);

setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
Expand Down
154 changes: 154 additions & 0 deletions apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { App, Modal } from "obsidian";
import { StrictMode, useState } from "react";
import { createRoot, Root } from "react-dom/client";
import { ColorPicker } from "~/components/RelationshipTypeSettings";
import type { DiscourseRelationType } from "~/types";
import {
DEFAULT_TLDRAW_COLOR,
type TldrawColorName,
} from "~/utils/tldrawColors";
import { getRelationTypeErrors } from "~/utils/typeUtils";

type RelationTypeFields = {
label: string;
complement: string;
color: TldrawColorName;
};

type CreateRelationTypeProps = {
// Read at submit: another canvas may add a type while the dialog is open
getRelationTypes: () => DiscourseRelationType[];
onSubmit: (fields: RelationTypeFields) => Promise<void>;
onClose: () => void;
};

const CreateRelationTypeForm = ({
getRelationTypes,
onSubmit,
onClose,
}: CreateRelationTypeProps) => {
const [label, setLabel] = useState("");
const [complement, setComplement] = useState("");
const [color, setColor] = useState<TldrawColorName>(DEFAULT_TLDRAW_COLOR);
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);

const handleSubmit = async () => {
// Trimmed so "supports " can't pass as a second "supports"
const fields = {
label: label.trim(),
complement: complement.trim(),
color,
};
const candidate = { id: "new", ...fields, created: 0, modified: 0 };
const relationTypes = getRelationTypes();
const nextError = !fields.label
? "Label is required"
: !fields.complement
? "Complement is required"
: (getRelationTypeErrors([...relationTypes, candidate])[
relationTypes.length
] ?? "");
Comment thread
trangdoan982 marked this conversation as resolved.
setError(nextError);
if (nextError) return;

setIsSubmitting(true);
try {
await onSubmit(fields);
Comment thread
trangdoan982 marked this conversation as resolved.
} catch {
setError("Couldn't save the relation type. Try again.");
setIsSubmitting(false);
return;
}
onClose();
};

const inputClassName = `w-full ${error ? "input-error" : ""}`;

return (
<form
className="flex flex-col gap-3"
onSubmit={(e) => {
e.preventDefault();
void handleSubmit();
}}
>
<input
type="text"
className={inputClassName}
placeholder="Label (e.g., supports)"
value={label}
onChange={(e) => setLabel(e.target.value)}
autoFocus
/>
<input
type="text"
className={inputClassName}
placeholder="Complement (e.g., is supported by)"
value={complement}
onChange={(e) => setComplement(e.target.value)}
/>
<ColorPicker value={color} onChange={setColor} />
{error && <div className="text-error text-xs">{error}</div>}
<div className="modal-button-container mt-2 flex justify-end gap-2">
<button
type="button"
className="mod-normal"
onClick={onClose}
disabled={isSubmitting}
>
Cancel
</button>
<button type="submit" className="mod-cta" disabled={isSubmitting}>
Continue
</button>
</div>
</form>
);
};

export class CreateRelationTypeModal extends Modal {
private root: Root | null = null;
private props: CreateRelationTypeProps;
private isSaving = false;

constructor(app: App, props: CreateRelationTypeProps) {
super(app);
this.props = props;
}

// Escape and the backdrop call close(); a pending save must finish first
close() {
if (!this.isSaving) super.close();
}

onOpen() {
this.setTitle("Add relation type");
// The color list is absolutely positioned and extends past this short form
this.modalEl.addClass("overflow-visible");
this.root = createRoot(this.contentEl);
this.root.render(
<StrictMode>
<CreateRelationTypeForm
getRelationTypes={this.props.getRelationTypes}
onSubmit={async (fields) => {
this.isSaving = true;
try {
await this.props.onSubmit(fields);
} finally {
this.isSaving = false;
}
}}
onClose={() => this.close()}
/>
</StrictMode>,
);
}

onClose() {
this.root?.unmount();
this.root = null;
this.contentEl.empty();
this.props.onClose();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import {
getArrowBindings,
getArrowInfo,
} from "~/components/canvas/utils/relationUtils";
import { CreateRelationTypeModal } from "~/components/canvas/CreateRelationTypeModal";
import {
associateRelationTypeWithNodePair,
createRelationTypeForNodePair,
getAssociableRelationTypesForNodePair,
getDiscourseNodeTypeId,
getValidRelationTypesForNodePair,
Expand Down Expand Up @@ -45,6 +47,8 @@ export const RelationTypeDropdown = ({
const [isAddMenuOpen, setIsAddMenuOpen] = useState(false);
const [isPickingExisting, setIsPickingExisting] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// The dialog sits outside dropdownRef, so its clicks and Escape must not dismiss the menu
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);

const arrow = useValue<DiscourseRelationShape | null>(
"dropdownArrow",
Expand Down Expand Up @@ -157,6 +161,7 @@ export const RelationTypeDropdown = ({
// Handle click outside
useEffect(() => {
const handlePointerDown = (e: PointerEvent) => {
if (isCreateDialogOpen) return;
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node)
Expand All @@ -174,12 +179,12 @@ export const RelationTypeDropdown = ({
clearTimeout(timer);
window.removeEventListener("pointerdown", handlePointerDown, true);
};
}, [onDismiss]);
}, [isCreateDialogOpen, onDismiss]);

// Handle Escape key: leave the picker or add menu first, then the dropdown
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (e.key !== "Escape" || isCreateDialogOpen) return;
if (isPickingExisting) {
e.stopPropagation();
setIsPickingExisting(false);
Expand All @@ -194,7 +199,7 @@ export const RelationTypeDropdown = ({
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [isAddMenuOpen, isPickingExisting, onDismiss]);
}, [isAddMenuOpen, isCreateDialogOpen, isPickingExisting, onDismiss]);

const handleSelect = useCallback(
(relationTypeId: string) => {
Expand All @@ -203,6 +208,23 @@ export const RelationTypeDropdown = ({
[onSelect],
);

const showRelationAddedToast = useCallback(
(relationTypeLabel: string) => {
if (!nodePair) return;
const sourceName =
getNodeTypeById(plugin, nodePair.sourceNodeTypeId)?.name ?? "source";
const targetName =
getNodeTypeById(plugin, nodePair.targetNodeTypeId)?.name ?? "target";
showToast({
severity: "success",
title: "Discourse relation added",
description: `${relationTypeLabel} relation added for ${sourceName} and ${targetName}`,
targetCanvasId: canvasPath,
});
},
[nodePair, plugin, canvasPath],
);

const handleAssociate = useCallback(
async (relationType: DiscourseRelationType) => {
if (!nodePair || isSaving) return;
Expand All @@ -224,20 +246,34 @@ export const RelationTypeDropdown = ({
setIsSaving(false);
}
setIsPickingExisting(false);
const sourceName =
getNodeTypeById(plugin, nodePair.sourceNodeTypeId)?.name ?? "source";
const targetName =
getNodeTypeById(plugin, nodePair.targetNodeTypeId)?.name ?? "target";
showToast({
severity: "success",
title: "Discourse relation added",
description: `${relationType.label} relation added for ${sourceName} and ${targetName}`,
targetCanvasId: canvasPath,
});
showRelationAddedToast(relationType.label);
},
[nodePair, isSaving, plugin, canvasPath],
[nodePair, isSaving, plugin, canvasPath, showRelationAddedToast],
);

const handleCreateNew = useCallback(() => {
if (!nodePair) return;
setIsAddMenuOpen(false);
setIsCreateDialogOpen(true);
new CreateRelationTypeModal(plugin.app, {
getRelationTypes: () => plugin.settings.relationTypes,
onSubmit: async (fields) => {
const relationType = await createRelationTypeForNodePair({
plugin,
...fields,
...nodePair,
});
showToast({
severity: "success",
title: "Relation type created",
targetCanvasId: canvasPath,
});
showRelationAddedToast(relationType.label);
},
onClose: () => setIsCreateDialogOpen(false),
}).open();
}, [nodePair, plugin, canvasPath, showRelationAddedToast]);

if (!dropdownPosition || !arrow) return null;

const centre = menuSize
Expand All @@ -262,7 +298,9 @@ export const RelationTypeDropdown = ({
>
Add existing…
</button>
<button className={actionClassName}>Create new</button>
<button className={actionClassName} onClick={handleCreateNew}>
Create new
</button>
</>
);
const hasRelationTypes = validRelationTypes.length > 0;
Expand Down
Loading
Loading