From 50de9c9f964cb592ba7919d134816158bc48ec3a Mon Sep 17 00:00:00 2001 From: Ehmad Saeed Date: Thu, 11 Sep 2025 19:41:03 +0500 Subject: [PATCH 1/3] feat: Add Link widget and corresponding tests --- .../src/ensemble/screens/widgets.yaml | 178 +++++++ packages/runtime/src/shared/coreSchema.ts | 2 + packages/runtime/src/widgets/Link.tsx | 157 ++++++ .../src/widgets/__tests__/Link.test.tsx | 493 ++++++++++++++++++ packages/runtime/src/widgets/index.ts | 1 + 5 files changed, 831 insertions(+) create mode 100644 packages/runtime/src/widgets/Link.tsx create mode 100644 packages/runtime/src/widgets/__tests__/Link.test.tsx diff --git a/apps/kitchen-sink/src/ensemble/screens/widgets.yaml b/apps/kitchen-sink/src/ensemble/screens/widgets.yaml index 87b554cbd..5946ea866 100644 --- a/apps/kitchen-sink/src/ensemble/screens/widgets.yaml +++ b/apps/kitchen-sink/src/ensemble/screens/widgets.yaml @@ -1,4 +1,6 @@ View: + inputs: + - userId styles: scrollableView: true onLoad: @@ -1295,6 +1297,182 @@ View: onRefresh: executeCode: | console.log('refreshed') + - Column: + styles: + padding: 20 + children: + - Text: + text: Link Widget Examples + styles: + fontSize: 24 + fontWeight: bold + marginBottom: 20 + + - Card: + styles: + marginBottom: 20 + children: + - Text: + text: Basic Internal Links + styles: + fontSize: 18 + fontWeight: 600 + marginBottom: 10 + - Link: + url: /widgets + widget: + Text: + text: Go to Widgets Page + - Text: + text: " | " + - Link: + url: /forms + styles: + color: blue + hoverColor: red + widget: + Text: + text: Forms Page (with hover effect) + + - Card: + styles: + marginBottom: 20 + children: + - Text: + text: External Links + styles: + fontSize: 18 + fontWeight: 600 + marginBottom: 10 + - Link: + url: https://google.com + openNewTab: true + widget: + Text: + text: Google (opens in new tab) + - Text: + text: " | " + - Link: + url: mailto:test@example.com + widget: + Text: + text: Send Email + + - Card: + styles: + marginBottom: 20 + children: + - Text: + text: Links with Custom Content + styles: + fontSize: 18 + fontWeight: 600 + marginBottom: 10 + - Link: + url: /home + styles: + textDecoration: none + color: darkblue + widget: + Row: + children: + - Icon: + name: home + styles: + marginRight: 8 + - Text: + text: Home Page + styles: + fontWeight: 500 + + - Card: + styles: + marginBottom: 20 + children: + - Text: + text: Link with Action + styles: + fontSize: 18 + fontWeight: 600 + marginBottom: 10 + - Link: + url: /actions + onTap: + executeCode: | + console.log('Link clicked!'); + ensemble.storage.set('lastClicked', 'Link to Actions'); + widget: + Column: + children: + - Text: + text: Actions Page (with console log) + + - Card: + styles: + marginBottom: 20 + children: + - Text: + text: Dynamic Link with Inputs + styles: + fontSize: 18 + fontWeight: 600 + marginBottom: 10 + - Button: + label: Set User ID + onTap: + executeCode: ensemble.storage.set('userId', 123) + - Text: + text: Refresh to see the Current User ID:${userId || 'not set'} + - Link: + url: /widgets + inputs: ${{"userId":ensemble.storage.get('userId')}} + widget: + Text: + text: Go to Widgets (with inputs) + + - Card: + children: + - Text: + text: Styled Links + styles: + fontSize: 18 + fontWeight: 600 + marginBottom: 10 + - Row: + children: + - Link: + url: /test1 + styles: + color: green + textDecoration: none + fontSize: 16 + widget: + Text: + text: Green Link + - Text: + text: " | " + - Link: + url: /test2 + styles: + color: orange + textDecoration: underline + fontWeight: bold + widget: + Text: + text: Orange Bold Link + - Text: + text: " | " + - Link: + url: /test3 + styles: + color: purple + hoverColor: magenta + textDecoration: none + hoverTextDecoration: underline + widget: + Text: + text: Purple Link (hover effects) + - Row: children: - Search: diff --git a/packages/runtime/src/shared/coreSchema.ts b/packages/runtime/src/shared/coreSchema.ts index e1a362504..2c3f55a18 100644 --- a/packages/runtime/src/shared/coreSchema.ts +++ b/packages/runtime/src/shared/coreSchema.ts @@ -24,6 +24,7 @@ import type { CardProps, FittedColumnProps, FittedRowProps, + LinkProps, } from "../widgets"; import type { Menu, View } from "./screenSchema"; import type { FlexboxProps, IconProps } from "./types"; @@ -194,6 +195,7 @@ export type Widget = // | { ConnectWithGoogle?: ConnectWithProvider } // | { ConnectWithMicrosoft?: ConnectWithProvider } | { Button?: ButtonProps } + | { Link?: LinkProps } // | { IconButton?: IconButton } // | { Address?: Address } | { Card?: ReplaceChildrenTemplate } diff --git a/packages/runtime/src/widgets/Link.tsx b/packages/runtime/src/widgets/Link.tsx new file mode 100644 index 000000000..3257f7a72 --- /dev/null +++ b/packages/runtime/src/widgets/Link.tsx @@ -0,0 +1,157 @@ +import type { Expression, EnsembleAction } from "@ensembleui/react-framework"; +import { useRegisterBindings, unwrapWidget } from "@ensembleui/react-framework"; +import { useMemo, useCallback } from "react"; +import { Link as RouterLink } from "react-router-dom"; +import { cloneDeep } from "lodash-es"; +import { WidgetRegistry } from "../registry"; +import type { EnsembleWidgetProps } from "../shared/types"; +import { EnsembleRuntime } from "../runtime"; +import { useEnsembleAction } from "../runtime/hooks/useEnsembleAction"; +import type { Widget } from "../shared/coreSchema"; + +const widgetName = "Link"; + +export interface LinkStyles { + /** @uiType color */ + color?: string; + /** @uiType color */ + hoverColor?: string; + textDecoration?: "none" | "underline" | "overline" | "line-through"; + hoverTextDecoration?: "none" | "underline" | "overline" | "line-through"; + fontSize?: string | number; + fontWeight?: string | number; + visible?: Expression; + cursor?: Expression; +} + +export type LinkProps = { + /** The URL or path to navigate to */ + url: Expression; + /** Action to execute when the link is clicked (optional, use for additional logic) */ + onTap?: EnsembleAction; + openNewTab?: Expression; + /** Whether to replace the current entry in the history stack */ + replace?: Expression; + /** Inputs to pass to the new route */ + inputs?: Expression<{ [key: string]: unknown }>; + /** Widget to render as link content */ + widget: Widget; + styles?: LinkStyles; +} & EnsembleWidgetProps; + +export const Link: React.FC = ({ id, onTap, widget, ...rest }) => { + const action = useEnsembleAction(onTap); + + const { values, rootRef } = useRegisterBindings({ ...rest, widgetName }, id); + + const handleClick = useCallback( + (_e: React.MouseEvent) => { + // If there's an onTap action, execute it but don't prevent default navigation + if (action?.callback) { + action.callback(); + } + }, + [action], + ); + const unwrappedWidget = unwrapWidget(cloneDeep(widget)); + + const linkStyles = useMemo(() => { + const baseStyles: React.CSSProperties = { + color: values?.styles?.color, + textDecoration: values?.styles?.textDecoration ?? "underline", + fontSize: values?.styles?.fontSize, + fontWeight: values?.styles?.fontWeight, + cursor: values?.styles?.cursor ?? "pointer", + ...(values?.styles?.visible === false ? { display: "none" } : undefined), + ...values?.styles, + }; + + return baseStyles; + }, [values?.styles]); + + const hoverStyles = useMemo(() => { + if (!values?.styles?.hoverColor && !values?.styles?.hoverTextDecoration) { + return {}; + } + + return { + color: values?.styles?.hoverColor, + textDecoration: values?.styles?.hoverTextDecoration, + }; + }, [values?.styles?.hoverColor, values?.styles?.hoverTextDecoration]); + + // Handle external URLs by using a regular anchor tag + const isExternalUrl = useMemo(() => { + const url = values?.url; + if (!url) return false; + return ( + url.startsWith("http://") || + url.startsWith("https://") || + url.startsWith("mailto:") || + url.startsWith("tel:") + ); + }, [values?.url]); + + if (!values?.url) { + return ( + + {EnsembleRuntime.render([unwrappedWidget])} + + ); + } + + if (isExternalUrl) { + return ( + { + if (hoverStyles.color) + e.currentTarget.style.color = hoverStyles.color; + if (hoverStyles.textDecoration) + e.currentTarget.style.textDecoration = hoverStyles.textDecoration; + }} + onMouseLeave={(e): void => { + if (values.styles?.color) + e.currentTarget.style.color = values.styles.color; + if (values?.styles?.textDecoration) + e.currentTarget.style.textDecoration = values.styles.textDecoration; + }} + ref={rootRef} + rel={values.openNewTab ? "noopener noreferrer" : undefined} + style={linkStyles} + target={values.openNewTab ? "_blank" : "_self"} + > + {EnsembleRuntime.render([unwrappedWidget])} + + ); + } + + // For internal navigation, use React Router Link + return ( + { + if (hoverStyles.color) e.currentTarget.style.color = hoverStyles.color; + if (hoverStyles.textDecoration) + e.currentTarget.style.textDecoration = hoverStyles.textDecoration; + }} + onMouseLeave={(e): void => { + if (values?.styles?.color) + e.currentTarget.style.color = values.styles.color; + if (values?.styles?.textDecoration) + e.currentTarget.style.textDecoration = values.styles.textDecoration; + }} + ref={rootRef} + replace={Boolean(values.replace)} + state={values.inputs} + style={linkStyles} + target={values.openNewTab ? "_blank" : undefined} + to={values.url} + > + {EnsembleRuntime.render([unwrappedWidget])} + + ); +}; + +WidgetRegistry.register(widgetName, Link); diff --git a/packages/runtime/src/widgets/__tests__/Link.test.tsx b/packages/runtime/src/widgets/__tests__/Link.test.tsx new file mode 100644 index 000000000..0e8a2988f --- /dev/null +++ b/packages/runtime/src/widgets/__tests__/Link.test.tsx @@ -0,0 +1,493 @@ +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { BrowserRouter, MemoryRouter, useLocation } from "react-router-dom"; +import { Link } from "../Link"; +import { Button } from "../Button"; +// Import widgets to register them for testing +import "../index"; + +// Mock window.open +const mockOpen = jest.fn(); +const originalOpen = window.open; + +beforeAll(() => { + window.open = mockOpen; +}); + +afterAll(() => { + window.open = originalOpen; +}); + +beforeEach(() => { + mockOpen.mockClear(); +}); + +describe("Link Widget", () => { + test("renders internal link with text children", () => { + render( + , + { wrapper: BrowserRouter }, + ); + + const linkElement = screen.getByText("About Us"); + expect(linkElement).toBeInTheDocument(); + expect(linkElement.closest("a")).toHaveAttribute("href", "/about"); + }); + + test("renders external link with correct attributes", () => { + render( + , + { wrapper: BrowserRouter }, + ); + + const linkElement = screen.getByText("Google"); + const anchorElement = linkElement.closest("a"); + + expect(anchorElement).toHaveAttribute("href", "https://google.com"); + expect(anchorElement).toHaveAttribute("target", "_blank"); + expect(anchorElement).toHaveAttribute("rel", "noopener noreferrer"); + }); + + test("renders with custom styles", () => { + render( + , + { wrapper: BrowserRouter }, + ); + + const linkElement = screen.getByText("Styled Link"); + const anchorElement = linkElement.closest("a"); + + expect(anchorElement).toHaveStyle({ + color: "red", + textDecoration: "none", + fontSize: "16px", + fontWeight: "bold", + }); + }); + + test("handles internal navigation with React Router", async () => { + let currentLocation: ReturnType; + + const LocationTracker = (): null => { + const location = useLocation(); + currentLocation = location; + return null; + }; + + render( + + + + , + ); + + const linkElement = screen.getByText("Widgets"); + fireEvent.click(linkElement); + + await waitFor(() => { + expect(currentLocation.pathname).toBe("/widgets"); + }); + }); + + test("handles external navigation by opening new window/tab", () => { + render( + , + { wrapper: BrowserRouter }, + ); + + const linkElement = screen.getByText("External Link"); + const anchorElement = linkElement.closest("a"); + + // External links should be handled as regular anchor links, not window.open + expect(anchorElement).toHaveAttribute("href", "https://example.com"); + expect(anchorElement).toHaveAttribute("target", "_self"); + }); + + test("handles external navigation with openNewTab", () => { + render( + , + { wrapper: BrowserRouter }, + ); + + const linkElement = screen.getByText("External Link"); + const anchorElement = linkElement.closest("a"); + + // External links with openNewTab should be handled as regular anchor links with target="_blank" + expect(anchorElement).toHaveAttribute("href", "https://example.com"); + expect(anchorElement).toHaveAttribute("target", "_blank"); + expect(anchorElement).toHaveAttribute("rel", "noopener noreferrer"); + }); + + test("executes onTap action when clicked", async () => { + const consoleSpy = jest.spyOn(console, "log").mockImplementation(jest.fn()); + + render( + , + { wrapper: BrowserRouter }, + ); + + const linkElement = screen.getByText("Clickable Link"); + fireEvent.click(linkElement); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith("Link clicked"); + }); + + consoleSpy.mockRestore(); + }); + + test("renders with replace prop for internal navigation", async () => { + let currentLocation: ReturnType; + + const LocationTracker = (): null => { + const location = useLocation(); + currentLocation = location; + return null; + }; + + render( + + + + , + ); + + const linkElement = screen.getByText("Replace Navigation"); + fireEvent.click(linkElement); + + await waitFor(() => { + expect(currentLocation.pathname).toBe("/actions"); + }); + }); + + test("renders with inputs prop for internal navigation", async () => { + let currentLocation: ReturnType; + + const LocationTracker = (): null => { + const location = useLocation(); + currentLocation = location; + return null; + }; + + render( + + + + , + ); + + const linkElement = screen.getByText("Link with Inputs"); + fireEvent.click(linkElement); + + await waitFor(() => { + expect(currentLocation.pathname).toBe("/with-inputs"); + expect(currentLocation.state).toEqual({ userId: 123, tab: "widgets" }); + }); + }); + + test("renders as span when no url is provided", () => { + render( + , + { wrapper: BrowserRouter }, + ); + + const linkElement = screen.getByText("No URL"); + expect(linkElement.tagName).toBe("SPAN"); + }); + + test("handles mailto and tel links as external", () => { + render( + <> + + + , + { wrapper: BrowserRouter }, + ); + + const emailLink = screen.getByText("Email Link").closest("a"); + const phoneLink = screen.getByText("Phone Link").closest("a"); + + expect(emailLink).toHaveAttribute("href", "mailto:test@example.com"); + expect(phoneLink).toHaveAttribute("href", "tel:+1234567890"); + }); + + test("handles hover styles correctly", () => { + render( + , + { wrapper: BrowserRouter }, + ); + + const linkElement = screen.getByText("Hover Link"); + const anchorElement = linkElement.closest("a")!; + + // Test initial styles + expect(anchorElement).toHaveStyle({ + color: "blue", + textDecoration: "underline", + }); + + // Test hover + fireEvent.mouseEnter(anchorElement); + expect(anchorElement).toHaveStyle({ + color: "red", + textDecoration: "none", + }); + + // Test mouse leave + fireEvent.mouseLeave(anchorElement); + expect(anchorElement).toHaveStyle({ + color: "blue", + textDecoration: "underline", + }); + }); + + test("renders with visibility hidden when visible is false", () => { + render( + , + { wrapper: BrowserRouter }, + ); + + const linkElement = screen.getByText("Hidden Link"); + const anchorElement = linkElement.closest("a"); + + expect(anchorElement).toHaveStyle({ display: "none" }); + }); + + test("handles complex children with multiple widgets", () => { + render( + , + { wrapper: BrowserRouter }, + ); + + expect(screen.getByText("Start")).toBeInTheDocument(); + expect(screen.getByText("End")).toBeInTheDocument(); + }); + + test("handles URL binding updates", async () => { + render( + <> + +