diff --git a/src/common/node.ts b/src/common/node.ts index 4a9b0e21..cebc0392 100644 --- a/src/common/node.ts +++ b/src/common/node.ts @@ -41,6 +41,11 @@ export const nodeSchema = z.object({ topicId: z.number(), arguedDiagramPartId: z.string().uuid().nullable(), type: zNodeTypes, + customType: z + .string() + .max(30) + .regex(/^[a-z ]+$/i) + .optional(), text: z.string().max(200), notes: z.string().max(10000), }); diff --git a/src/db/migrations/20231218132412_add_custom_node_labels/down.sql b/src/db/migrations/20231218132412_add_custom_node_labels/down.sql new file mode 100644 index 00000000..6c0967b9 --- /dev/null +++ b/src/db/migrations/20231218132412_add_custom_node_labels/down.sql @@ -0,0 +1,6 @@ +BEGIN; + +-- AlterTable +ALTER TABLE "nodes" DROP COLUMN "customType"; + +COMMIT; diff --git a/src/db/migrations/20231218132412_add_custom_node_labels/migration.sql b/src/db/migrations/20231218132412_add_custom_node_labels/migration.sql new file mode 100644 index 00000000..0bdce54f --- /dev/null +++ b/src/db/migrations/20231218132412_add_custom_node_labels/migration.sql @@ -0,0 +1,6 @@ +BEGIN; + +-- AlterTable +ALTER TABLE "nodes" ADD COLUMN "customType" VARCHAR(30); + +COMMIT; diff --git a/src/db/schema.prisma b/src/db/schema.prisma index 83a21e2a..46d8eb1e 100644 --- a/src/db/schema.prisma +++ b/src/db/schema.prisma @@ -57,6 +57,7 @@ model Node { topicId Int arguedDiagramPartId String? @db.Uuid // only set if this is a claim node; ideally this would be FK but could point to either Node or Edge type NodeType + customType String? @db.VarChar(30) // arbitrary max, "Solution Component" is 18 chars text String @db.VarChar(200) // arbitrary max, ~50 chars fit on 3 lines of the node's text area notes String @default("") @db.VarChar(10000) // arbitrarily large max length createdAt DateTime @default(now()) diff --git a/src/web/topic/components/Node/EditableNode.tsx b/src/web/topic/components/Node/EditableNode.tsx index 4bba00e7..82bb08e9 100644 --- a/src/web/topic/components/Node/EditableNode.tsx +++ b/src/web/topic/components/Node/EditableNode.tsx @@ -3,8 +3,9 @@ import { useEffect, useRef } from "react"; import { useSessionUser } from "../../../common/hooks"; import { openContextMenu } from "../../../common/store/contextMenuActions"; +import { useUnrestrictedEditing } from "../../../view/actionConfigStore"; import { setSelected, useIsGraphPartSelected } from "../../../view/navigateStore"; -import { finishAddingNode, setNodeLabel } from "../../store/actions"; +import { finishAddingNode, setCustomNodeType, setNodeLabel } from "../../store/actions"; import { useUserCanEditTopicData } from "../../store/userHooks"; import { Node } from "../../utils/graph"; import { nodeDecorations } from "../../utils/node"; @@ -35,6 +36,7 @@ interface Props { export const EditableNode = ({ node, supplemental = false, className = "" }: Props) => { const { sessionUser } = useSessionUser(); const userCanEditTopicData = useUserCanEditTopicData(sessionUser?.username); + const unrestrictedEditing = useUnrestrictedEditing(); const selected = useIsGraphPartSelected(node.id); const theme = useTheme(); @@ -64,6 +66,7 @@ export const EditableNode = ({ node, supplemental = false, className = "" }: Pro const nodeDecoration = nodeDecorations[node.type]; const color = theme.palette[node.type].main; const NodeIcon = nodeDecoration.NodeIcon; + const typeText = node.data.customType ?? nodeDecoration.title; // Require selecting a node before editing it, because oftentimes you'll want to select a node to // view more details, and the editing will be distracting. Only edit after clicking when selected. @@ -80,7 +83,17 @@ export const EditableNode = ({ node, supplemental = false, className = "" }: Pro - {nodeDecoration.title} + { + if (event.target.textContent && event.target.textContent !== node.data.customType) + setCustomNodeType(node, event.target.textContent.trim()); + }} + className="nopan" + > + {typeText} + diff --git a/src/web/topic/store/actions.ts b/src/web/topic/store/actions.ts index 37edd5b4..d9fb3763 100644 --- a/src/web/topic/store/actions.ts +++ b/src/web/topic/store/actions.ts @@ -3,6 +3,7 @@ import set from "lodash/set"; import { edgeSchema } from "../../../common/edge"; import { errorWithData } from "../../../common/errorHandling"; +import { nodeSchema } from "../../../common/node"; import { Edge, GraphPart, @@ -63,6 +64,21 @@ export const setNodeLabel = (node: Node, value: string) => { useTopicStore.setState(finishDraft(state), false, "setNodeLabel"); }; +export const setCustomNodeType = (node: Node, value: string) => { + const state = createDraft(useTopicStore.getState()); + + if (!nodeSchema.shape.customType.parse(value)) + throw errorWithData("label should only contain alphaspace", value); + + const foundNode = findNode(node.id, state.nodes); + + /* eslint-disable functional/immutable-data, no-param-reassign */ + foundNode.data.customType = value; + /* eslint-enable functional/immutable-data, no-param-reassign */ + + useTopicStore.setState(finishDraft(state), false, "setCustomEdgeLabel"); +}; + export const setCustomEdgeLabel = (edge: Edge, value: string) => { const state = createDraft(useTopicStore.getState()); diff --git a/src/web/topic/utils/apiConversion.ts b/src/web/topic/utils/apiConversion.ts index da218fdf..210f39f0 100644 --- a/src/web/topic/utils/apiConversion.ts +++ b/src/web/topic/utils/apiConversion.ts @@ -17,6 +17,7 @@ export const convertToStoreNode = (apiNode: TopicNode) => { notes: apiNode.notes, type: apiNode.type, arguedDiagramPartId: apiNode.arguedDiagramPartId ?? undefined, + customType: apiNode.customType ?? undefined, }); }; @@ -56,6 +57,7 @@ export const convertToApiNode = (storeNode: StoreNode, topicId: number): ApiNode topicId: topicId, arguedDiagramPartId: storeNode.data.arguedDiagramPartId ?? null, type: storeNode.type, + customType: storeNode.data.customType, text: storeNode.data.label, notes: storeNode.data.notes, }; diff --git a/src/web/topic/utils/edge.ts b/src/web/topic/utils/edge.ts index c648acae..c48360b7 100644 --- a/src/web/topic/utils/edge.ts +++ b/src/web/topic/utils/edge.ts @@ -91,14 +91,15 @@ export const getRelation = ( child: NodeType, relationName?: RelationName ): Relation | undefined => { - if (relationName) { - return relations.find( - (relation) => - relation.parent === parent && relation.child === child && relation.name === relationName - ); - } else { - return relations.find((relation) => relation.parent === parent && relation.child === child); - } + const relation = relationName + ? relations.find( + (relation) => + relation.parent === parent && relation.child === child && relation.name === relationName + ) + : relations.find((relation) => relation.parent === parent && relation.child === child); + + // we're assuming that anything can relate to anything else; potentially this should only be true when unrestricted editing is on + return relation ?? { child, name: "relatesTo", parent }; }; export const composedRelations: Relation[] = [ diff --git a/src/web/topic/utils/graph.ts b/src/web/topic/utils/graph.ts index 712dacb3..8063764b 100644 --- a/src/web/topic/utils/graph.ts +++ b/src/web/topic/utils/graph.ts @@ -15,6 +15,10 @@ export interface Graph { export interface Node { id: string; data: { + /** + * Distinguished from `type` because this is explicitly open user input, and `type` can maintain stricter typing + */ + customType?: string; label: string; notes: string; arguedDiagramPartId?: string; @@ -30,15 +34,24 @@ export interface ProblemNode extends Node { interface BuildProps { id?: string; + customType?: string; label?: string; notes?: string; type: FlowNodeType; arguedDiagramPartId?: string; } -export const buildNode = ({ id, label, notes, type, arguedDiagramPartId }: BuildProps): Node => { +export const buildNode = ({ + id, + customType, + label, + notes, + type, + arguedDiagramPartId, +}: BuildProps): Node => { const node = { id: id ?? uuid(), data: { + customType: customType, label: label ?? `new node`, notes: notes ?? "", arguedDiagramPartId: arguedDiagramPartId, @@ -59,12 +72,12 @@ export type RelationDirection = "parent" | "child"; export interface Edge { id: string; data: { - arguedDiagramPartId?: string; - notes: string; /** * Distinguished from `label` because this is explicitly open user input, and `label` can maintain stricter typing */ customLabel?: string; + notes: string; + arguedDiagramPartId?: string; }; label: RelationName; markerStart: { type: MarkerType; width: number; height: number }; @@ -91,28 +104,28 @@ export interface Edge { interface BuildEdgeProps { id?: string; + customLabel?: string; notes?: string; sourceId: string; targetId: string; relation: RelationName; arguedDiagramPartId?: string; - customLabel?: string; } export const buildEdge = ({ id, + customLabel, notes, sourceId, targetId, relation, arguedDiagramPartId, - customLabel, }: BuildEdgeProps): Edge => { return { id: id ?? uuid(), data: { - arguedDiagramPartId: arguedDiagramPartId, - notes: notes ?? "", customLabel: customLabel, + notes: notes ?? "", + arguedDiagramPartId: arguedDiagramPartId, }, label: relation, markerStart: markerStart,