diff --git a/src/components/Dialogs/AddFormDialog.tsx b/src/components/Dialogs/AddFormDialog.tsx index 4c2c3658..954ded0b 100644 --- a/src/components/Dialogs/AddFormDialog.tsx +++ b/src/components/Dialogs/AddFormDialog.tsx @@ -10,12 +10,18 @@ * * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 ********************************************************************************/ -import { forwardRef, useContext, useState, useImperativeHandle } from "react"; +import React, { + forwardRef, + useContext, + useState, + useImperativeHandle, +} from "react"; import ReactDOM from "react-dom"; import ediTDorContext from "../../context/ediTDorContext"; import { checkIfFormIsInItem } from "../../utils/tdOperations"; import DialogTemplate from "./DialogTemplate"; import AddForm from "../App/AddForm"; +import type { IExplicitForm, IInteractionAffordance } from "../../types/form"; export type OperationsType = "property" | "action" | "event" | "thing" | ""; export type OperationsMap = PropertyMap | ActionMap | EventMap | ThingMap; @@ -36,23 +42,18 @@ type ThingMap = ] | []; -export interface AddFormDialogRef { +export interface IAddFormDialogRef { openModal: () => void; close: () => void; } -export interface ExplicitForm { - op: string[] | string; - href: string; -} - -interface AddFormDialogProps { +interface IAddFormDialogProps { type?: OperationsType; - interaction?: { forms?: ExplicitForm[]; type?: string }; + interaction?: IInteractionAffordance; interactionName?: string; } -const AddFormDialog = forwardRef( +const AddFormDialog = forwardRef( (props, ref) => { const context: IEdiTDorContext = useContext(ediTDorContext); const [display, setDisplay] = useState(() => { @@ -64,7 +65,7 @@ const AddFormDialog = forwardRef( const type: OperationsType = props.type || ""; const name = type && type[0].toUpperCase() + type.slice(1); - const interaction = props.interaction ?? {}; + const interaction = props.interaction; const interactionName = props.interactionName ?? ""; useImperativeHandle(ref, () => { @@ -111,10 +112,10 @@ const AddFormDialog = forwardRef( } }; - const checkDuplicates = (form: ExplicitForm): boolean => { + const checkDuplicates = (form: IExplicitForm): boolean => { const isDuplicate: boolean = - interaction.forms !== undefined - ? checkIfFormIsInItem(form, interaction as { forms: ExplicitForm[] }) + interaction?.forms !== undefined + ? checkIfFormIsInItem(form, { forms: interaction.forms }) : false; return isDuplicate; }; @@ -131,7 +132,7 @@ const AddFormDialog = forwardRef( }; const onHandleEventRightButton = () => { - const form: ExplicitForm = { + const form: IExplicitForm = { op: operations(type) .map((x) => { const element = document.getElementById( diff --git a/src/components/Dialogs/AddPropertyDialog.tsx b/src/components/Dialogs/AddPropertyDialog.tsx index cafef313..8abc9d4a 100644 --- a/src/components/Dialogs/AddPropertyDialog.tsx +++ b/src/components/Dialogs/AddPropertyDialog.tsx @@ -27,12 +27,12 @@ import DialogTemplate from "./DialogTemplate"; const NO_TYPE = "undefined"; -export interface AddPropertyDialogRef { +export interface IAddPropertyDialogRef { openModal: () => void; close?: () => void; } -interface Property { +interface IProperty { title: string; description?: string; type?: string; @@ -43,7 +43,7 @@ interface Property { properties?: Record; } -export const AddPropertyDialog = forwardRef( +export const AddPropertyDialog = forwardRef( (_, ref) => { const context = useContext(ediTDorContext); const [display, setDisplay] = React.useState(() => { @@ -115,7 +115,7 @@ export const AddPropertyDialog = forwardRef( return; } - const property: Property = { + const property: IProperty = { title: (document.getElementById(`${type}-title`) as HTMLInputElement) .value, observable: ( diff --git a/src/components/TDViewer/components/Action.tsx b/src/components/TDViewer/components/Action.tsx index 6d4323dd..93f52009 100644 --- a/src/components/TDViewer/components/Action.tsx +++ b/src/components/TDViewer/components/Action.tsx @@ -10,8 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 ********************************************************************************/ -import React, { useContext, useState } from "react"; -import { Trash2 } from "react-feather"; +import React, { useContext, useRef } from "react"; import ediTDorContext from "../../../context/ediTDorContext"; import { buildAttributeListObject, @@ -22,102 +21,103 @@ import InfoIconWrapper from "../../base/InfoIconWrapper"; import { getFormsTooltipContent } from "../../../utils/TooltipMapper"; import Form from "./Form"; import AddFormElement from "../base/AddFormElement"; - +import AffordanceButtons from "./AffordanceButtons"; +import type { IInteractionAffordance } from "../../../types/form"; +import { useCopiedAffordanceFocus } from "../../../hooks/useCopiedAffordanceFocus"; const alreadyRenderedKeys = ["title", "forms", "description"]; -const Action: React.FC = (props) => { +interface IAction { + action: IInteractionAffordance; + actionName: string; + copiedToken?: number; + onCopy: () => void; +} +const Action: React.FC = ({ + action, + actionName, + copiedToken, + onCopy, +}) => { const context = useContext(ediTDorContext); - - const [isExpanded, setIsExpanded] = useState(false); - - const addFormDialog = React.useRef(); - const handleOpenAddFormDialog = () => { - addFormDialog.current.openModal(); - }; - - if ( - Object.keys(props.action).length === 0 && - props.action.constructor !== Object - ) { - return ( -
- Action could not be rendered because mandatory fields are missing. -
- ); - } - - const action = props.action; - const forms = separateForms(props.action.forms); - + const addFormDialog = useRef<{ openModal: () => void; close: () => void }>( + null + ); + const { containerRef, isExpanded, isHighlighted, setIsExpanded } = + useCopiedAffordanceFocus({ copiedToken }); + const forms = separateForms(action.forms); const attributeListObject = buildAttributeListObject( - { name: props.actionName }, - props.action, + { name: actionName }, + action, alreadyRenderedKeys ); - const attributes = Object.keys(attributeListObject).map((x) => { - return ( -
  • - {x} : {JSON.stringify(attributeListObject[x])} -
  • - ); - }); - - const handleDeleteAction = () => { - context.removeOneOfAKindReducer("actions", props.actionName); + const handleDelete = () => { + context.removeOneOfAKindReducer("actions", actionName); }; return (
    setIsExpanded(!isExpanded)} + onToggle={(e) => setIsExpanded(e.currentTarget.open)} > - -

    {action.title ?? props.actionName}

    + +

    {action.title ?? actionName}

    + {isExpanded && ( - + { + e.preventDefault(); + e.stopPropagation(); + onCopy(); + }} + onDelete={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleDelete(); + }} + /> )}
    -
    +
    {action.description && (
    {action.description}
    )} -
      {attributes}
    -
    - -

    Forms

    -
    -
    +
      + {Object.entries(attributeListObject).map(([k, v]) => ( +
    • + {k}: {JSON.stringify(v)} +
    • + ))} +
    + + +

    Forms

    +
    + + addFormDialog.current?.openModal()} /> - + {forms.map((form, i) => (
    + propName={actionName} + interactionType="action" + /> ))}
    diff --git a/src/components/TDViewer/components/AffordanceButtons.tsx b/src/components/TDViewer/components/AffordanceButtons.tsx new file mode 100644 index 00000000..349e924b --- /dev/null +++ b/src/components/TDViewer/components/AffordanceButtons.tsx @@ -0,0 +1,52 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +import React from "react"; +import { Copy, Trash2 } from "react-feather"; + +interface IProps { + onCopy: (e: React.MouseEvent) => void; + onDelete: (e: React.MouseEvent) => void; + copyTitle: string; + deleteTitle: string; +} + +const AffordanceButtons: React.FC = ({ + onCopy, + onDelete, + copyTitle, + deleteTitle, +}) => { + return ( +
    + + + +
    + ); +}; + +export default AffordanceButtons; diff --git a/src/components/TDViewer/components/Event.tsx b/src/components/TDViewer/components/Event.tsx index e47fa950..fd7776f2 100644 --- a/src/components/TDViewer/components/Event.tsx +++ b/src/components/TDViewer/components/Event.tsx @@ -10,8 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 ********************************************************************************/ -import React, { useContext, useState } from "react"; -import { Trash2 } from "react-feather"; +import React, { useContext, useRef } from "react"; import ediTDorContext from "../../../context/ediTDorContext"; import { buildAttributeListObject, @@ -22,101 +21,90 @@ import InfoIconWrapper from "../../base/InfoIconWrapper"; import { getFormsTooltipContent } from "../../../utils/TooltipMapper"; import Form from "./Form"; import AddFormElement from "../base/AddFormElement"; - +import AffordanceButtons from "./AffordanceButtons"; +import type { IInteractionAffordance } from "../../../types/form"; +import { useCopiedAffordanceFocus } from "../../../hooks/useCopiedAffordanceFocus"; const alreadyRenderedKeys = ["title", "forms", "description"]; - -const Event: React.FC = (props) => { +interface IEvent { + event: IInteractionAffordance; + eventName: string; + copiedToken?: number; + onCopy: () => void; +} +const Event: React.FC = ({ event, eventName, copiedToken, onCopy }) => { const context = useContext(ediTDorContext); - - const [isExpanded, setIsExpanded] = useState(false); - - const addFormDialog = React.useRef(); - const handleOpenAddFormDialog = () => { - addFormDialog.current.openModal(); - }; - - if ( - Object.keys(props.event).length === 0 && - props.event.constructor !== Object - ) { - return ( -
    - Event could not be rendered because mandatory fields are missing. -
    - ); - } - - const event = props.event; - const forms = separateForms(props.event.forms); + const addFormDialog = useRef<{ openModal: () => void; close: () => void }>( + null + ); + const { containerRef, isExpanded, isHighlighted, setIsExpanded } = + useCopiedAffordanceFocus({ copiedToken }); + const forms = separateForms(event.forms); const attributeListObject = buildAttributeListObject( - { name: props.eventName }, - props.event, + { name: eventName }, + event, alreadyRenderedKeys ); - const attributes = Object.keys(attributeListObject).map((x) => { - return ( -
  • - {x} : {JSON.stringify(attributeListObject[x])} -
  • - ); - }); - - const handleDeleteEventClicked = () => { - context.removeOneOfAKindReducer("events", props.eventName); + const handleDelete = () => { + context.removeOneOfAKindReducer("events", eventName); }; return (
    setIsExpanded(!isExpanded)} + onToggle={(e) => setIsExpanded(e.currentTarget.open)} > - -
    {event.title ?? props.eventName}
    + +
    {event.title ?? eventName}
    {isExpanded && ( - + { + e.preventDefault(); + e.stopPropagation(); + onCopy(); + }} + onDelete={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleDelete(); + }} + /> )}
    - -
    +
    {event.description && (
    {event.description}
    )} -
      {attributes}
    - -
    - -

    Forms

    -
    -
    - - +
      + {Object.entries(attributeListObject).map(([k, v]) => ( +
    • + {k}: {JSON.stringify(v)} +
    • + ))} +
    + +

    Forms

    +
    + addFormDialog.current?.openModal()} /> {forms.map((form, i) => (
    + propName={eventName} + interactionType="event" + /> ))}
    diff --git a/src/components/TDViewer/components/Form.tsx b/src/components/TDViewer/components/Form.tsx index 59eeedf7..b1937fb6 100644 --- a/src/components/TDViewer/components/Form.tsx +++ b/src/components/TDViewer/components/Form.tsx @@ -15,7 +15,7 @@ import ediTDorContext from "../../../context/ediTDorContext"; import FormDetails from "../base/FormDetails"; import UndefinedForm from "../base/UndefinedForm"; import { formConfigurations } from "../../../services/form"; -import type { OpKeys } from "../../../types/form"; +import type { FormProps, OpKeys } from "../../../types/form"; const typeToJSONKey = (type: string): string => { const typeToJSONKey: Record = { @@ -29,12 +29,7 @@ const typeToJSONKey = (type: string): string => { }; interface IFormComponentProps { - form: { - href: string; - contentType: string; - op: string; - actualIndex: number; - }; + form: FormProps; propName: string; interactionType: "thing" | "property" | "action" | "event"; } diff --git a/src/components/TDViewer/components/InteractionSection.tsx b/src/components/TDViewer/components/InteractionSection.tsx index ee11ce37..8751b556 100644 --- a/src/components/TDViewer/components/InteractionSection.tsx +++ b/src/components/TDViewer/components/InteractionSection.tsx @@ -38,6 +38,8 @@ import ErrorDialog from "../../Dialogs/ErrorDialog"; import { readAllReadablePropertyForms } from "../../../services/thingsApiService"; import { AlertTriangle } from "react-feather"; import { getErrorSummary } from "../../../utils/arrays"; +import { copyAffordance } from "../../../utils/copyAffordance"; +import type { IInteractionAffordance } from "../../../types/form"; const SORT_ASC = "asc"; const SORT_DESC = "desc"; @@ -49,6 +51,12 @@ interface IInteractionSectionProps { type InteractionKey = "properties" | "actions" | "events"; +interface ICopiedAffordanceTarget { + section: InteractionKey; + name: string; + token: number; +} + /** * Renders a section for an interaction (Property, Action, Event) with a * search bar, a sorting icon and a button to add a new interaction. @@ -99,6 +107,8 @@ const InteractionSection: React.FC = (props) => { >({}); const [isTestingAll, setIsTestingAll] = useState(false); + const [copiedAffordance, setCopiedAffordance] = + useState(null); const interaction = props.interaction.toLowerCase() as InteractionKey; @@ -381,6 +391,26 @@ const InteractionSection: React.FC = (props) => { }); }; + const handleCopyAffordance = ( + section: InteractionKey, + originalName: string, + affordance: IInteractionAffordance + ) => { + const { updatedTD, newName } = copyAffordance({ + parsedTD: context.parsedTD, + section, + originalName, + affordance, + }); + + context.updateOfflineTD(JSON.stringify(updatedTD, null, 2)); + setCopiedAffordance({ + section, + name: newName, + token: Date.now(), + }); + }; + const buildChildren = () => { const filteredInteractions = applyFilter(); @@ -390,7 +420,22 @@ const InteractionSection: React.FC = (props) => { + handleCopyAffordance( + "properties", + key, + ( + filteredInteractions as Record + )[key] + ) + } + key={key} /> )); } @@ -475,20 +520,50 @@ const InteractionSection: React.FC = (props) => { ); } if (td.actions && interaction === "actions") { - return Object.keys(filteredInteractions).map((key, index) => ( + return Object.keys(filteredInteractions).map((key) => ( + handleCopyAffordance( + "actions", + key, + (filteredInteractions as Record)[ + key + ] + ) + } + key={key} /> )); } if (td.events && interaction === "events") { - return Object.keys(filteredInteractions).map((key, index) => ( + return Object.keys(filteredInteractions).map((key) => ( + handleCopyAffordance( + "events", + key, + (filteredInteractions as Record)[ + key + ] + ) + } + key={key} /> )); } diff --git a/src/components/TDViewer/components/Property.tsx b/src/components/TDViewer/components/Property.tsx index b75a086d..90b55328 100644 --- a/src/components/TDViewer/components/Property.tsx +++ b/src/components/TDViewer/components/Property.tsx @@ -10,8 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 ********************************************************************************/ -import React, { useContext, useState, useRef } from "react"; -import { Trash2 } from "react-feather"; +import React, { useContext, useRef } from "react"; import ediTDorContext from "../../../context/ediTDorContext"; import { buildAttributeListObject, @@ -22,119 +21,97 @@ import { getFormsTooltipContent } from "../../../utils/TooltipMapper"; import Form from "./Form"; import AddFormDialog from "../../Dialogs/AddFormDialog"; import AddFormElement from "../base/AddFormElement"; - -const alreadyRenderedKeys = ["title", "forms", "description"]; +import AffordanceButtons from "./AffordanceButtons"; +import type { IInteractionAffordance } from "../../../types/form"; +import { useCopiedAffordanceFocus } from "../../../hooks/useCopiedAffordanceFocus"; interface IProperty { - prop: { - title: string; - forms: Array<{ - href: string; - contentType?: string; - op?: string | string[]; - [key: string]: any; - }>; - description?: string; - [key: string]: any; - }; + prop: IInteractionAffordance; propName: string; + copiedToken?: number; + onCopy: () => void; } -const Property: React.FC = (props) => { - const context = useContext(ediTDorContext); - - const [isExpanded, setIsExpanded] = useState(false); - - const addFormDialog = useRef<{ openModal: () => void }>(null); - const handleOpenAddFormDialog = () => { - addFormDialog.current?.openModal(); - }; - - if ( - Object.keys(props.prop).length === 0 && - props.prop.constructor !== Object - ) { - return ( -
    - Property could not be rendered because mandatory fields are missing. -
    - ); - } - - const property = props.prop; - let forms: { - href: string; - op: string; - contentType: string; - actualIndex: number; - [key: string]: any; - }[] = separateForms(structuredClone(props.prop.forms)); +const alreadyRenderedKeys = ["title", "forms", "description"]; +const Property: React.FC = ({ + prop, + propName, + copiedToken, + onCopy, +}) => { + const context = useContext(ediTDorContext); + const addFormDialog = useRef<{ openModal: () => void; close: () => void }>( + null + ); + const { containerRef, isExpanded, isHighlighted, setIsExpanded } = + useCopiedAffordanceFocus({ copiedToken }); + const forms = separateForms(structuredClone(prop.forms)); const attributeListObject = buildAttributeListObject( - { name: props.propName }, - props.prop, + { name: propName }, + prop, alreadyRenderedKeys ); - const attributes = Object.keys(attributeListObject).map((x) => { - return ( -
  • - {x} : {JSON.stringify(attributeListObject[x])} -
  • - ); - }); - - const handleDeletePropertyClicked = () => { - context.removeOneOfAKindReducer("properties", props.propName); + const handleDeleteProperty = () => { + context.removeOneOfAKindReducer("properties", propName); }; return (
    setIsExpanded(!isExpanded)} + onToggle={(e) => setIsExpanded(e.currentTarget.open)} > - -

    {property.title ?? props.propName}

    + +

    {prop.title ?? propName}

    {isExpanded && ( - + { + e.preventDefault(); + e.stopPropagation(); + onCopy(); + }} + onDelete={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleDeleteProperty(); + }} + /> )}
    - -
    - {property.description && ( +
    + {prop.description && (
    - {property.description} + {prop.description}
    )} -
      {attributes}
    - -
    - -

    Forms

    -
    -
    - - +
      + {Object.entries(attributeListObject).map(([k, v]) => ( +
    • + {k}: {JSON.stringify(v)} +
    • + ))} +
    + +

    Forms

    +
    + addFormDialog.current?.openModal()} /> - {forms.map((form, i) => (
    ))}
    diff --git a/src/hooks/useCopiedAffordanceFocus.ts b/src/hooks/useCopiedAffordanceFocus.ts new file mode 100644 index 00000000..b4223e3d --- /dev/null +++ b/src/hooks/useCopiedAffordanceFocus.ts @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState } from "react"; + +interface IUseCopiedAffordanceFocusOptions { + copiedToken?: number; + highlightDurationMs?: number; +} + +interface IUseCopiedAffordanceFocusResult { + containerRef: React.RefObject; + isExpanded: boolean; + isHighlighted: boolean; + setIsExpanded: React.Dispatch>; +} + +export const useCopiedAffordanceFocus = ({ + copiedToken, + highlightDurationMs = 3000, +}: IUseCopiedAffordanceFocusOptions): IUseCopiedAffordanceFocusResult => { + const containerRef = useRef(null); + const highlightTimeoutRef = useRef(null); + const [isExpanded, setIsExpanded] = useState(false); + const [isHighlighted, setIsHighlighted] = useState(false); + + useEffect(() => { + if (copiedToken === undefined) { + return; + } + + setIsExpanded(true); + setIsHighlighted(true); + containerRef.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + + if (highlightTimeoutRef.current !== null) { + window.clearTimeout(highlightTimeoutRef.current); + } + + highlightTimeoutRef.current = window.setTimeout(() => { + setIsHighlighted(false); + }, highlightDurationMs); + + return () => { + if (highlightTimeoutRef.current !== null) { + window.clearTimeout(highlightTimeoutRef.current); + highlightTimeoutRef.current = null; + } + }; + }, [copiedToken, highlightDurationMs]); + + useEffect(() => { + return () => { + if (highlightTimeoutRef.current !== null) { + window.clearTimeout(highlightTimeoutRef.current); + } + }; + }, []); + + return { + containerRef, + isExpanded, + isHighlighted, + setIsExpanded, + }; +}; diff --git a/src/tests/InteractionSection.integration.test.tsx b/src/tests/InteractionSection.integration.test.tsx new file mode 100644 index 00000000..752f0721 --- /dev/null +++ b/src/tests/InteractionSection.integration.test.tsx @@ -0,0 +1,315 @@ +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import InteractionSection from "../components/TDViewer/components/InteractionSection"; +import ediTDorContext from "../context/ediTDorContext"; +import { editdorReducer } from "../context/editorReducers"; +import { + THING_DESCRIPTION_LAMP_JSON, + THING_DESCRIPTION_LAMP_V_STRING, + createContextValue, +} from "./constants"; + +vi.mock("@node-wot/browser-bundle", () => ({ + Core: { + Servient: vi.fn().mockImplementation(() => ({ + addClientFactory: vi.fn(), + start: vi.fn().mockResolvedValue({ + consume: vi.fn(), + }), + })), + }, + Http: { + HttpClientFactory: vi.fn(), + }, +})); + +vi.mock("@monaco-editor/react", () => ({ + default: () =>
    , +})); + +vi.mock("../components/Dialogs/AddActionDialog", async () => { + const React = await import("react"); + + const MockAddActionDialog = React.forwardRef((_props, ref) => { + React.useImperativeHandle(ref, () => ({ openModal: () => undefined })); + return null; + }); + + return { default: MockAddActionDialog }; +}); + +vi.mock("../components/Dialogs/AddEventDialog", async () => { + const React = await import("react"); + + const MockAddEventDialog = React.forwardRef((_props, ref) => { + React.useImperativeHandle(ref, () => ({ openModal: () => undefined })); + return null; + }); + + return { default: MockAddEventDialog }; +}); + +vi.mock("../components/Dialogs/AddPropertyDialog", async () => { + const React = await import("react"); + + const MockAddPropertyDialog = React.forwardRef((_props, ref) => { + React.useImperativeHandle(ref, () => ({ openModal: () => undefined })); + return null; + }); + + return { AddPropertyDialog: MockAddPropertyDialog }; +}); + +vi.mock("../components/Dialogs/AddFormDialog", async () => { + const React = await import("react"); + + const MockAddFormDialog = React.forwardRef((_props, ref) => { + React.useImperativeHandle(ref, () => ({ + openModal: () => undefined, + close: () => undefined, + })); + return null; + }); + + return { default: MockAddFormDialog }; +}); + +vi.mock("../components/Dialogs/ErrorDialog", () => ({ + default: () => null, +})); + +vi.mock("../components/Dialogs/DialogTemplate", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), +})); + +vi.mock("../components/TDViewer/components/EditProperties", () => ({ + default: () => null, +})); + +const createInitialEditorState = (): EditorState => { + const value = createContextValue({ + offlineTD: THING_DESCRIPTION_LAMP_V_STRING, + parsedTD: structuredClone(THING_DESCRIPTION_LAMP_JSON), + isValidJSON: true, + name: THING_DESCRIPTION_LAMP_JSON.title, + }); + + return { + offlineTD: value.offlineTD, + isValidJSON: value.isValidJSON, + parsedTD: value.parsedTD, + isModified: value.isModified, + name: value.name, + fileHandle: value.fileHandle, + linkedTd: value.linkedTd, + validationMessage: value.validationMessage, + northboundConnection: value.northboundConnection, + contributeCatalog: value.contributeCatalog, + }; +}; + +const TestContextProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [state, dispatch] = React.useReducer( + editdorReducer, + undefined, + createInitialEditorState + ); + + const contextValue = createContextValue({ + offlineTD: state.offlineTD, + isValidJSON: state.isValidJSON, + parsedTD: state.parsedTD, + isModified: state.isModified, + name: state.name, + fileHandle: state.fileHandle, + linkedTd: state.linkedTd, + validationMessage: state.validationMessage, + northboundConnection: state.northboundConnection, + contributeCatalog: state.contributeCatalog, + updateOfflineTD: (offlineTD: string) => { + dispatch({ type: "UPDATE_OFFLINE_TD", offlineTD }); + }, + removeOneOfAKindReducer: ( + kind: "properties" | "actions" | "events" | string, + oneOfAKindName: string + ) => { + dispatch({ type: "REMOVE_ONE_OF_A_KIND_FROM_TD", kind, oneOfAKindName }); + }, + }); + + return ( + + {children} + + ); +}; + +const OfflineTdProbe: React.FC = () => { + const { offlineTD } = React.useContext(ediTDorContext); + + return
    {offlineTD}
    ; +}; + +const getOfflineTdJson = () => { + const offlineTd = screen.getByTestId("offline-td").textContent ?? "{}"; + + return JSON.parse(offlineTd); +}; + +describe("InteractionSection affordance flows", () => { + beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: vi.fn(), + }); + }); + + test("copies a property, opens the new affordance, and highlights it", async () => { + render( + + + + + ); + + fireEvent.click(screen.getByText("status")); + const copyButton = await screen.findByRole("button", { + name: /copy property/i, + }); + fireEvent.click(copyButton); + + await waitFor(() => { + const td = getOfflineTdJson(); + + expect(td.properties).toHaveProperty("status"); + expect(td.properties).toHaveProperty("status_copy"); + expect(td.actions).toHaveProperty("toggle"); + expect(td.events).toHaveProperty("overheating"); + }); + + await waitFor(() => { + expect(screen.getByText("status_copy")).toBeInTheDocument(); + }); + + const copiedAffordance = document.getElementById("property-status_copy"); + + expect(copiedAffordance).toHaveAttribute("open"); + expect(copiedAffordance).toHaveClass("border-green-400"); + expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalled(); + }); + + test("copies an action, opens the new affordance, and updates parsed offlineTd", async () => { + render( + + + + + ); + + fireEvent.click(screen.getByText("toggle")); + + fireEvent.click( + await screen.findByRole("button", { name: /copy action/i }) + ); + + await waitFor(() => { + const td = getOfflineTdJson(); + + expect(td.actions).toHaveProperty("toggle"); + expect(td.actions).toHaveProperty("toggle_copy"); + expect(td.properties).toHaveProperty("status"); + }); + + await waitFor(() => { + expect(screen.getByText("toggle_copy")).toBeInTheDocument(); + }); + + const copiedAffordance = document.getElementById("action-toggle_copy"); + + expect(copiedAffordance).toHaveAttribute("open"); + expect(copiedAffordance).toHaveClass("border-green-400"); + }); + + test("hides and shows the copy affordance button with property expansion state", async () => { + render( + + + + ); + + expect( + screen.queryByRole("button", { name: /copy property/i }) + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText("status")); + + expect( + await screen.findByRole("button", { name: /copy property/i }) + ).toBeInTheDocument(); + + fireEvent.click(screen.getByText("status")); + + await waitFor(() => { + expect( + screen.queryByRole("button", { name: /copy property/i }) + ).not.toBeInTheDocument(); + }); + }); + + test("hides and shows the delete affordance button with property expansion state", async () => { + render( + + + + ); + + expect( + screen.queryByRole("button", { name: /delete property/i }) + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText("status")); + + expect( + await screen.findByRole("button", { name: /delete property/i }) + ).toBeInTheDocument(); + + fireEvent.click(screen.getByText("status")); + + await waitFor(() => { + expect( + screen.queryByRole("button", { name: /delete property/i }) + ).not.toBeInTheDocument(); + }); + }); + + test("deletes a property and updates offlineTd", async () => { + render( + + + + + ); + + fireEvent.click(screen.getByText("status")); + fireEvent.click( + await screen.findByRole("button", { name: /delete property/i }) + ); + + await waitFor(() => { + const td = getOfflineTdJson(); + + expect(td.properties).not.toHaveProperty("status"); + expect(td.actions).toHaveProperty("toggle"); + expect(td.events).toHaveProperty("overheating"); + }); + + await waitFor(() => { + expect(screen.queryByText("status")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/types/form.d.ts b/src/types/form.d.ts index ab2619f1..f6b49378 100644 --- a/src/types/form.d.ts +++ b/src/types/form.d.ts @@ -9,7 +9,7 @@ * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and * * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 - ********************************************************************************/ ß; + ********************************************************************************/ import { ThingDescription } from "wot-thing-description-types"; type ServientCallback = ( @@ -26,14 +26,31 @@ export interface IFormConfigurations { callback: ServientCallback | null; } -interface IFormProps { +export interface IInteractionForm { href: string; + op?: string | string[]; + contentType?: string; + [key: string]: unknown; +} + +export interface IExplicitForm extends IInteractionForm { op: string | string[]; - propName: string; - actualIndex: number; } -type OpKeys = +export interface IInteractionAffordance { + title?: string; + description?: string; + type?: string; + forms?: IInteractionForm[]; + [key: string]: unknown; +} + +export type IFormProps = IInteractionForm & { + actualIndex: number; + [key: string]: unknown; +}; + +export type OpKeys = | "readproperty" | "writeproperty" | "observeproperty" diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 415a0cb8..da93cd44 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -55,4 +55,7 @@ declare global { quality?: number; } } + +export type InteractionKey = "actions" | "properties" | "events"; + export {}; diff --git a/src/utils/copyAffordance.test.ts b/src/utils/copyAffordance.test.ts new file mode 100644 index 00000000..074faa0f --- /dev/null +++ b/src/utils/copyAffordance.test.ts @@ -0,0 +1,184 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ + +import { describe, expect, test } from "vitest"; +import { copyAffordance } from "./copyAffordance"; + +describe("copyAffordance", () => { + test("creates a copy with a unique name appended with '_copy'", () => { + const td = { + actions: { + reset: { + title: "Reset Sensor", + forms: [], + }, + }, + }; + + const { updatedTD, newName } = copyAffordance({ + parsedTD: td, + section: "actions", + originalName: "reset", + affordance: td.actions.reset, + }); + + expect(newName).toBe("reset_copy"); + expect(updatedTD.actions[newName]).toBeDefined(); + expect(updatedTD.actions[newName].title).toBe("Reset Sensor copy"); + }); + + test("increments name suffix when '_copy' already exists", () => { + const resetAffordance = { title: "Reset", forms: [] }; + const td = { + actions: { + reset: resetAffordance, + reset_copy: { title: "Reset copy", forms: [] }, + }, + }; + + const { newName } = copyAffordance({ + parsedTD: td, + section: "actions", + originalName: "reset", + affordance: resetAffordance, + }); + + expect(newName).toBe("reset_copy_1"); + }); + + test("increments counter further when multiple copies already exist", () => { + const resetAffordance = { title: "Reset", forms: [] }; + const td = { + actions: { + reset: resetAffordance, + reset_copy: { title: "Reset copy", forms: [] }, + reset_copy_1: { title: "Reset copy 1", forms: [] }, + }, + }; + + const { newName } = copyAffordance({ + parsedTD: td, + section: "actions", + originalName: "reset", + affordance: resetAffordance, + }); + + expect(newName).toBe("reset_copy_2"); + }); + + test("does not append 'copy' to title when affordance has no title", () => { + const affordance = { forms: [] }; + const td = { + properties: { + temperature: affordance, + }, + }; + + const { updatedTD, newName } = copyAffordance({ + parsedTD: td, + section: "properties", + originalName: "temperature", + affordance, + }); + + expect(newName).toBe("temperature_copy"); + expect(updatedTD.properties[newName].title).toBeUndefined(); + }); + + test("inserts the copied affordance immediately after the original", () => { + const tdAffordance = { title: "Toggle", forms: [] }; + const td = { + actions: { + start: { title: "Start", forms: [] }, + toggle: tdAffordance, + stop: { title: "Stop", forms: [] }, + }, + }; + + const { updatedTD } = copyAffordance({ + parsedTD: td, + section: "actions", + originalName: "toggle", + affordance: tdAffordance, + }); + + const keys = Object.keys(updatedTD.actions); + const originalIndex = keys.indexOf("toggle"); + const copyIndex = keys.indexOf("toggle_copy"); + + expect(copyIndex).toBe(originalIndex + 1); + }); + + test("deep clones the affordance so original is not mutated", () => { + const affordance = { title: "Read", forms: [{ href: "/read" }] }; + const td = { + properties: { + status: affordance, + }, + }; + + const { updatedTD, newName } = copyAffordance({ + parsedTD: td, + section: "properties", + originalName: "status", + affordance, + }); + + // Mutating the copy should not affect the original + updatedTD.properties[newName].forms[0].href = "/mutated"; + expect(affordance.forms[0].href).toBe("/read"); + }); + + test("supports 'events' section", () => { + const eventAffordance = { title: "Overheat Alert", forms: [] }; + const td = { + events: { + overheat: eventAffordance, + }, + }; + + const { updatedTD, newName } = copyAffordance({ + parsedTD: td, + section: "events", + originalName: "overheat", + affordance: eventAffordance, + }); + + expect(newName).toBe("overheat_copy"); + expect(updatedTD.events[newName].title).toBe("Overheat Alert copy"); + }); + + test("throws an error when parsedTD is null", () => { + expect(() => + copyAffordance({ + parsedTD: null, + section: "actions", + originalName: "reset", + affordance: {}, + }) + ).toThrow('TD or section "actions" missing'); + }); + + test("throws an error when the section does not exist in parsedTD", () => { + const td = { properties: {} }; + + expect(() => + copyAffordance({ + parsedTD: td, + section: "actions", + originalName: "reset", + affordance: {}, + }) + ).toThrow('TD or section "actions" missing'); + }); +}); diff --git a/src/utils/copyAffordance.ts b/src/utils/copyAffordance.ts new file mode 100644 index 00000000..4d855106 --- /dev/null +++ b/src/utils/copyAffordance.ts @@ -0,0 +1,66 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +import type { InteractionKey } from "../types/global"; + +interface CopyAffordanceParams { + parsedTD: any; + section: InteractionKey; + originalName: string; + affordance: any; +} + +export function copyAffordance({ + parsedTD, + section, + originalName, + affordance, +}: CopyAffordanceParams): { updatedTD: any; newName: string } { + if (!parsedTD || !parsedTD[section]) { + throw new Error(`TD or section "${section}" missing`); + } + + const originalSection = parsedTD[section]; + + let newName = `${originalName}_copy`; + let counter = 1; + + while (originalSection[newName]) { + newName = `${originalName}_copy_${counter++}`; + } + + const copiedAffordance = structuredClone(affordance); + + if (copiedAffordance.title) { + copiedAffordance.title = `${copiedAffordance.title} copy`; + } + + const entries = Object.entries(originalSection); + const newEntries: [string, any][] = []; + + for (const [key, value] of entries) { + newEntries.push([key, value]); + + if (key === originalName) { + newEntries.push([newName, copiedAffordance]); + } + } + + const updatedSection = Object.fromEntries(newEntries); + + const updatedTD = { + ...parsedTD, + [section]: updatedSection, + }; + + return { updatedTD, newName }; +} diff --git a/src/utils/tdOperations.ts b/src/utils/tdOperations.ts index 28b08f9a..6a167649 100644 --- a/src/utils/tdOperations.ts +++ b/src/utils/tdOperations.ts @@ -11,8 +11,8 @@ * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 ********************************************************************************/ -import { ExplicitForm } from "components/Dialogs/AddFormDialog"; import { direction } from "direction"; +import type { ExplicitForm, FormProps, InteractionForm } from "types/form"; /** * @param {Object} td @@ -69,26 +69,32 @@ export const buildAttributeListObject = ( * Converts Forms that have an array as the "op" value into multiple separate Forms * which only have a string as "op" value. */ -export const separateForms = (forms) => { - if (forms === undefined && !forms) { +export const separateForms = ( + forms: InteractionForm[] | undefined +): FormProps[] => { + if (forms === undefined || forms === null) { return []; } - const newForms = []; + const newForms: FormProps[] = []; for (let i = 0; i < forms.length; i++) { const form = forms[i]; if (!Array.isArray(form.op)) { - form.actualIndex = i; - newForms.push(form); + newForms.push({ + ...form, + actualIndex: i, + }); continue; } for (let j = 0; j < form.op.length; j++) { - const temp = { ...form }; - temp.op = form.op[j]; - temp.actualIndex = i; + const temp: FormProps = { + ...form, + op: form.op[j], + actualIndex: i, + }; newForms.push(temp); } } @@ -110,7 +116,7 @@ export const checkIfLinkIsInItem = (link, itemToCheck) => { export const checkIfFormIsInItem = ( form: ExplicitForm, - itemToCheck: { forms: ExplicitForm[] } + itemToCheck: { forms: InteractionForm[] } ): boolean => { for (const element of itemToCheck.forms) { if (typeof element.op === "undefined") { @@ -148,7 +154,7 @@ export const checkIfFormIsInItem = ( export const checkIfFormIsInElement = ( form: ExplicitForm & { op: string }, - element: ExplicitForm + element: InteractionForm ): boolean => { if (typeof element.op === "undefined") { return false;