Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions app/src/features/objects/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
import { CopyableId } from "../../shared/ui/CopyableId";
import { InfiniteScrollSentinel } from "../../shared/ui/InfiniteScrollSentinel";
import { LoadingIndicator } from "../../shared/ui/LoadingIndicator";
import { PinIcon } from "../../shared/ui/PinIcon";
import { ProfileHoverName } from "../../shared/ui/ProfileHoverCard";
import { CommentaryPanel } from "./components/CommentaryPanel";
import { MarkdownBody } from "./components/MarkdownBody";
Expand All @@ -37,6 +38,10 @@ type Props = {
onRevealInGraph: () => void;
onArchive: () => Promise<void>;
onUnarchive: () => Promise<void>;
favorited: boolean;
pinned: boolean;
onSetFavorite: (favorited: boolean) => Promise<void>;
onSetPin: (pinned: boolean) => Promise<void>;
onAccessChanged: () => Promise<void>;
onUpdate: (input: UpdateObjectRequest) => Promise<boolean>;
};
Expand Down Expand Up @@ -297,6 +302,10 @@ export function ObjectView({
onRevealInGraph,
onArchive,
onUnarchive,
favorited,
pinned,
onSetFavorite,
onSetPin,
onAccessChanged,
onUpdate,
}: Props) {
Expand Down Expand Up @@ -757,8 +766,6 @@ export function ObjectView({
: `Historical · version ${selectedVersion.version_number}`}
</span>

<CopyableId value={object.id} displayValue={`ID: ${object.id}`} label="object ID" />

{isCurrentVersion && (
<span style={styles.objectUpdatedBy}>
Updated {formatTimestamp(object.updated_at)} by {updatedBy}
Expand All @@ -767,6 +774,43 @@ export function ObjectView({
</div>

<div style={styles.sectionActions}>
{!isArchived && (
<>
<button
type="button"
style={pinned ? styles.pinButtonActive : styles.pinButton}
aria-label={
pinned ? `Unpin ${selectedVersion.title}` : `Pin ${selectedVersion.title}`
}
aria-pressed={pinned}
title={pinned ? "Unpin object" : "Pin object"}
onClick={() => void onSetPin(!pinned)}
>
<PinIcon active={pinned} />
</button>
<button
type="button"
style={styles.favoriteButton}
aria-label={
favorited
? `Remove ${selectedVersion.title} from favorites`
: `Add ${selectedVersion.title} to favorites`
}
aria-pressed={favorited}
title={favorited ? "Remove from favorites" : "Add to favorites"}
onClick={() => void onSetFavorite(!favorited)}
>
{favorited ? "★" : "☆"}
</button>
</>
)}
<CopyableId
value={object.id}
displayValue={`ID: ${object.id}`}
label="object ID"
iconOnly
/>

{!isCurrentVersion && (
<button
type="button"
Expand Down
235 changes: 176 additions & 59 deletions app/src/features/objects/components/ContextPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,50 +20,68 @@ type Props = {
type CreationContextPanelProps = {
workspaceId: string;
draftTitle: string;
connectedFrom: Pick<ObjectSummary, "id" | "title"> | null;
objects: ObjectSummary[];
connections: Array<{
object: Pick<ObjectSummary, "id" | "title">;
direction: "incoming" | "outgoing";
}>;
onOpenObject: (objectId: string) => void;
onAddConnection: (connection: {
object: Pick<ObjectSummary, "id" | "title">;
direction: "incoming" | "outgoing";
}) => void;
onRemoveConnection: (objectId: string) => void;
};

export function CreationContextPanel({
workspaceId,
draftTitle,
connectedFrom,
objects,
connections,
onOpenObject,
onAddConnection,
onRemoveConnection,
}: CreationContextPanelProps) {
const [creatingConnection, setCreatingConnection] = useState(false);
const [targetQuery, setTargetQuery] = useState("");
const [direction, setDirection] = useState<"incoming" | "outgoing">("outgoing");
const connectedIds = new Set(connections.map((connection) => connection.object.id));
const normalizedTargetQuery = targetQuery.trim().toLowerCase();
const filteredTargets = objects
.filter(
(object) =>
!connectedIds.has(object.id) &&
(!normalizedTargetQuery || object.title.toLowerCase().includes(normalizedTargetQuery)),
)
.slice(0, 8);
const incoming = connections.filter((connection) => connection.direction === "incoming");
const outgoing = connections.filter((connection) => connection.direction === "outgoing");
const graphContext = useMemo<ObjectContext>(() => {
const timestamp = new Date().toISOString();
const draftObjectId = "draft-object";
const sourceNodes = connectedFrom
? [
{
id: connectedFrom.id,
workspace_id: workspaceId,
current_version_id: null,
title: connectedFrom.title,
status: "active" as const,
created_by: null,
created_at: timestamp,
updated_at: timestamp,
distance: 1,
incoming_count: 0,
outgoing_count: 1,
},
]
: [];
const sourceEdges = connectedFrom
? [
{
id: "draft-edge",
workspace_id: workspaceId,
source_object_id: connectedFrom.id,
target_object_id: draftObjectId,
kind: "relationship" as const,
created_by: null,
created_at: timestamp,
updated_at: timestamp,
},
]
: [];
const connectionNodes = connections.map((connection) => ({
id: connection.object.id,
workspace_id: workspaceId,
current_version_id: null,
title: connection.object.title,
status: "active" as const,
created_by: null,
created_at: timestamp,
updated_at: timestamp,
distance: 1,
incoming_count: connection.direction === "outgoing" ? 1 : 0,
outgoing_count: connection.direction === "incoming" ? 1 : 0,
}));
const connectionEdges = connections.map((connection, index) => ({
id: `draft-edge-${index}`,
workspace_id: workspaceId,
source_object_id: connection.direction === "incoming" ? connection.object.id : draftObjectId,
target_object_id: connection.direction === "incoming" ? draftObjectId : connection.object.id,
kind: "relationship" as const,
created_by: null,
created_at: timestamp,
updated_at: timestamp,
}));

return {
backlinks: {
Expand All @@ -77,8 +95,8 @@ export function CreationContextPanel({
root_object_id: draftObjectId,
depth: 1,
direction: "both",
max_nodes: connectedFrom ? 2 : 1,
max_edges: connectedFrom ? 1 : 0,
max_nodes: connections.length + 1,
max_edges: connections.length,
truncated: false,
truncation: { nodes: false, edges: false },
nodes: [
Expand All @@ -92,15 +110,59 @@ export function CreationContextPanel({
created_at: timestamp,
updated_at: timestamp,
distance: 0,
incoming_count: connectedFrom ? 1 : 0,
outgoing_count: 0,
incoming_count: incoming.length,
outgoing_count: outgoing.length,
},
...sourceNodes,
...connectionNodes,
],
edges: sourceEdges,
edges: connectionEdges,
},
};
}, [connectedFrom, draftTitle, workspaceId]);
}, [connections, draftTitle, incoming.length, outgoing.length, workspaceId]);

function addConnection(object: ObjectSummary) {
onAddConnection({ object: { id: object.id, title: object.title }, direction });
setTargetQuery("");
setCreatingConnection(false);
}

function connectionList(values: typeof connections, label: "Incoming" | "Outgoing") {
if (values.length === 0) {
return null;
}

return (
<div style={styles.contextBlock}>
<div style={styles.contextHeading}>
<strong>{label}</strong>

<span style={styles.contextCount}>{values.length}</span>
</div>

<div style={styles.connectionList}>
{values.map((connection) => (
<div key={connection.object.id} style={styles.connectionRow}>
<button
type="button"
style={styles.connectionItem}
onClick={() => onOpenObject(connection.object.id)}
>
<strong style={styles.connectionTitle}>{connection.object.title}</strong>
</button>
<button
type="button"
style={styles.connectionRemove}
aria-label={`Remove connection to ${connection.object.title}`}
onClick={() => onRemoveConnection(connection.object.id)}
>
×
</button>
</div>
))}
</div>
</div>
);
}

return (
<aside style={styles.contextPanel}>
Expand All @@ -112,31 +174,86 @@ export function CreationContextPanel({
<div style={styles.contextHeading}>
<strong>Connections</strong>

<span style={styles.contextCount}>{connectedFrom ? 1 : 0}</span>
<span style={styles.contextCount}>{connections.length}</span>
</div>

{!connectedFrom && <span style={styles.muted}>No connected objects yet.</span>}
</div>
{connections.length === 0 && <span style={styles.muted}>No connected objects yet.</span>}

{connectedFrom && (
<div style={styles.contextBlock}>
<div style={styles.contextHeading}>
<strong>Incoming</strong>
{!creatingConnection && (
<button
type="button"
style={styles.contextAction}
onClick={() => setCreatingConnection(true)}
>
Add connection
</button>
)}

<span style={styles.contextCount}>1</span>
</div>
{creatingConnection && (
<div style={styles.connectionEditor}>
<label style={styles.field}>
<span>Direction</span>
<select
value={direction}
style={styles.input}
onChange={(event) => setDirection(event.target.value as "incoming" | "outgoing")}
>
<option value="outgoing">Outgoing</option>
<option value="incoming">Incoming</option>
</select>
</label>

<div style={styles.connectionList}>
<button
type="button"
style={styles.connectionItem}
onClick={() => onOpenObject(connectedFrom.id)}
>
<strong style={styles.connectionTitle}>{connectedFrom.title}</strong>
</button>
<div style={styles.field}>
<span>Object</span>
<input
data-1p-ignore="true"
autoFocus
value={targetQuery}
onChange={(event) => setTargetQuery(event.target.value)}
onKeyDown={(event) =>
selectSoleResultOnEnter(event, filteredTargets, addConnection)
}
style={styles.input}
placeholder="Search objects…"
autoComplete="off"
/>

<div style={styles.connectionTargetResults}>
{filteredTargets.map((object) => (
<button
key={object.id}
type="button"
style={styles.connectionTargetResult}
onClick={() => addConnection(object)}
>
<strong>{object.title}</strong>
</button>
))}

{filteredTargets.length === 0 && (
<span style={styles.muted}>No matching objects.</span>
)}
</div>
</div>

<div style={styles.connectionEditorActions}>
<button
type="button"
style={styles.secondaryButton}
onClick={() => {
setCreatingConnection(false);
setTargetQuery("");
}}
>
Cancel
</button>
</div>
</div>
</div>
)}
)}
</div>

{connectionList(outgoing, "Outgoing")}
{connectionList(incoming, "Incoming")}
</aside>
);
}
Expand Down
Loading
Loading