From 87ca8e386e4ff093fe80ef873038cc0c47a4d2bf Mon Sep 17 00:00:00 2001 From: GerardasB <10091419+GerardasB@users.noreply.github.com> Date: Wed, 11 Dec 2024 17:38:38 +0200 Subject: [PATCH] Add `ToolUtilities` to define tool icon as a React element (#1150) * Add ToolUtilities * Update Tool types to include iconElement property. * NextVersion.md * Add note * Extract API * Add unit tests * Update test * rush change * Update snaps --- .../appui/frontstages/MainFrontstage.tsx | 16 +++- common/api/appui-react.api.md | 28 ++----- common/api/imodel-components-react.api.md | 7 ++ common/api/summary/appui-react.exports.csv | 4 +- .../imodel-components-react.exports.csv | 1 + .../tool-icons_2024-12-11-13-21.json | 10 +++ .../tool-icons_2024-12-11-13-21.json | 10 +++ docs/changehistory/NextVersion.md | 46 ++++++++++++ ...toolbar-composer-test-1-chromium-linux.png | Bin 3416 -> 3518 bytes .../src/appui-react/icons/SvgViewLayouts.tsx | 18 +++++ .../toolbar/ToolbarItemUtilities.tsx | 6 ++ ...nPaletteTools.ts => KeyinPaletteTools.tsx} | 24 +++--- ...enSettingsTool.ts => OpenSettingsTool.tsx} | 21 ++++-- ...oreLayoutTool.ts => RestoreLayoutTool.tsx} | 38 +++++++--- .../toolbar/ToolbarItemUtilities.test.tsx | 24 ++++++ .../src/imodel-components-react.ts | 2 + .../imodel-components-react/ToolUtilities.ts | 52 +++++++++++++ .../src/test/ToolUtilities.test.tsx | 70 ++++++++++++++++++ 18 files changed, 325 insertions(+), 52 deletions(-) create mode 100644 common/changes/@itwin/appui-react/tool-icons_2024-12-11-13-21.json create mode 100644 common/changes/@itwin/imodel-components-react/tool-icons_2024-12-11-13-21.json create mode 100644 ui/appui-react/src/appui-react/icons/SvgViewLayouts.tsx rename ui/appui-react/src/appui-react/tools/{KeyinPaletteTools.ts => KeyinPaletteTools.tsx} (70%) rename ui/appui-react/src/appui-react/tools/{OpenSettingsTool.ts => OpenSettingsTool.tsx} (74%) rename ui/appui-react/src/appui-react/tools/{RestoreLayoutTool.ts => RestoreLayoutTool.tsx} (76%) create mode 100644 ui/appui-react/src/test/toolbar/ToolbarItemUtilities.test.tsx create mode 100644 ui/imodel-components-react/src/imodel-components-react/ToolUtilities.ts create mode 100644 ui/imodel-components-react/src/test/ToolUtilities.test.tsx diff --git a/apps/test-app/src/frontend/appui/frontstages/MainFrontstage.tsx b/apps/test-app/src/frontend/appui/frontstages/MainFrontstage.tsx index 7e1a3887b62..e5a44d3f5bf 100644 --- a/apps/test-app/src/frontend/appui/frontstages/MainFrontstage.tsx +++ b/apps/test-app/src/frontend/appui/frontstages/MainFrontstage.tsx @@ -7,9 +7,13 @@ import { BackstageAppButton, BackstageItemUtilities, FrontstageUtilities, + RestoreFrontstageLayoutTool, SettingsModalFrontstage, StageUsage, StandardContentLayouts, + ToolbarItemUtilities, + ToolbarOrientation, + ToolbarUsage, UiItemsProvider, } from "@itwin/appui-react"; import { @@ -46,7 +50,17 @@ createMainFrontstage.stageId = "main"; export function createMainFrontstageProvider() { return { id: "appui-test-app:backstageItemsProvider", - getToolbarItems: () => [getCustomViewSelectorPopupItem()], + getToolbarItems: () => [ + getCustomViewSelectorPopupItem(), + ToolbarItemUtilities.createForTool(RestoreFrontstageLayoutTool, { + layouts: { + standard: { + orientation: ToolbarOrientation.Horizontal, + usage: ToolbarUsage.ContentManipulation, + }, + }, + }), + ], getBackstageItems: () => [ BackstageItemUtilities.createStageLauncher({ stageId: createMainFrontstage.stageId, diff --git a/common/api/appui-react.api.md b/common/api/appui-react.api.md index bb1155703a0..6c0cf508e61 100644 --- a/common/api/appui-react.api.md +++ b/common/api/appui-react.api.md @@ -3665,30 +3665,14 @@ export class ReducerRegistry { export const ReducerRegistryInstance: ReducerRegistry; // @public -export class RestoreAllFrontstagesTool extends Tool { - // (undocumented) - static iconSpec: string; - // (undocumented) - run(): Promise; - // (undocumented) - static toolId: string; -} +export const RestoreAllFrontstagesTool: typeof RestoreAllFrontstagesCoreTool & { + iconElement: React_2.ReactElement; +}; // @public -export class RestoreFrontstageLayoutTool extends Tool { - // (undocumented) - static iconSpec: string; - // (undocumented) - static get maxArgs(): number; - // (undocumented) - static get minArgs(): number; - // (undocumented) - parseAndRun(...args: string[]): Promise; - // (undocumented) - run(frontstageId?: string): Promise; - // (undocumented) - static toolId: string; -} +export const RestoreFrontstageLayoutTool: typeof RestoreFrontstageLayoutCoreTool & { + iconElement: React_2.ReactElement; +}; // @public export const SafeAreaContext: React_2.Context; diff --git a/common/api/imodel-components-react.api.md b/common/api/imodel-components-react.api.md index b226f051a7b..f0fb75dd575 100644 --- a/common/api/imodel-components-react.api.md +++ b/common/api/imodel-components-react.api.md @@ -27,6 +27,7 @@ import { ScreenViewport } from '@itwin/core-frontend'; import type { Slider } from '@itwin/itwinui-react'; import type { StandardViewId } from '@itwin/core-frontend'; import type { TentativePoint } from '@itwin/core-frontend'; +import type { ToolType } from '@itwin/core-frontend'; import type { TypeEditor } from '@itwin/components-react'; import { UiEvent } from '@itwin/appui-abstract'; import type { UnitProps } from '@itwin/core-quantity'; @@ -748,6 +749,12 @@ export enum TimelineScale { Years = 0 } +// @public +export namespace ToolUtilities { + export function defineIcon(toolType: T, iconElement: React_2.ReactElement): ToolWithIcon; + export function isWithIcon(toolType: T): toolType is ToolWithIcon; +} + // @public export class UiIModelComponents { static initialize(): Promise; diff --git a/common/api/summary/appui-react.exports.csv b/common/api/summary/appui-react.exports.csv index 7b8efd0ff10..25d339bf969 100644 --- a/common/api/summary/appui-react.exports.csv +++ b/common/api/summary/appui-react.exports.csv @@ -523,8 +523,8 @@ public;class;ReducerRegistry deprecated;class;ReducerRegistry beta;const;ReducerRegistryInstance deprecated;const;ReducerRegistryInstance -public;class;RestoreAllFrontstagesTool -public;class;RestoreFrontstageLayoutTool +public;const;RestoreAllFrontstagesTool +public;const;RestoreFrontstageLayoutTool public;const;SafeAreaContext public;enum;SafeAreaInsets public;class;ScheduleAnimationTimelineDataProvider diff --git a/common/api/summary/imodel-components-react.exports.csv b/common/api/summary/imodel-components-react.exports.csv index e9a2a3aea55..7a58a0dbef4 100644 --- a/common/api/summary/imodel-components-react.exports.csv +++ b/common/api/summary/imodel-components-react.exports.csv @@ -101,6 +101,7 @@ public;interface;TimelineMenuItemProps public;enum;TimelinePausePlayAction public;interface;TimelinePausePlayArgs public;enum;TimelineScale +public;namespace;ToolUtilities public;class;UiIModelComponents public;class;ViewClassFullNameChangedEvent deprecated;class;ViewClassFullNameChangedEvent diff --git a/common/changes/@itwin/appui-react/tool-icons_2024-12-11-13-21.json b/common/changes/@itwin/appui-react/tool-icons_2024-12-11-13-21.json new file mode 100644 index 00000000000..3393c35368c --- /dev/null +++ b/common/changes/@itwin/appui-react/tool-icons_2024-12-11-13-21.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/appui-react", + "comment": "", + "type": "none" + } + ], + "packageName": "@itwin/appui-react" +} \ No newline at end of file diff --git a/common/changes/@itwin/imodel-components-react/tool-icons_2024-12-11-13-21.json b/common/changes/@itwin/imodel-components-react/tool-icons_2024-12-11-13-21.json new file mode 100644 index 00000000000..abd87cf9bfa --- /dev/null +++ b/common/changes/@itwin/imodel-components-react/tool-icons_2024-12-11-13-21.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/imodel-components-react", + "comment": "Add `ToolUtilities` to define a Tool icon as a React element.", + "type": "none" + } + ], + "packageName": "@itwin/imodel-components-react" +} \ No newline at end of file diff --git a/docs/changehistory/NextVersion.md b/docs/changehistory/NextVersion.md index 7783ba5ecda..2ced82a8a94 100644 --- a/docs/changehistory/NextVersion.md +++ b/docs/changehistory/NextVersion.md @@ -2,9 +2,55 @@ - [@itwin/components-react](#itwincomponents-react) - [Additions](#additions) +- [@itwin/imodel-components-react](#itwinimodel-components-react) + - [Additions](#additions-1) ## @itwin/components-react ### Additions - Added a callback to `VirtualizedPropertyGrid` which determines which editors should always be visible. [#1090](https://github.com/iTwin/appui/pull/1090) + +## @itwin/imodel-components-react + +### Additions + +- Added `ToolUtilities` namespace that contains utilities for working with iTwin.js core `Tool` class. [1150](https://github.com/iTwin/appui/pull/1150) + + - `ToolUtilities.defineIcon` function allows defining an icon for a tool type using a React element. This is a supplement for an existing `Tool.iconSpec` property that adds additional `iconElement` property to the tool type. + + ```tsx + // Before + export class MyTool extends Tool { + public static iconSpec = "icon-placeholder"; + } + + // After + class MyCoreTool extends Tool { + public static iconSpec = "icon-placeholder"; + } + export const MyTool = ToolUtilities.defineIcon( + MyCoreTool, + + ); + ``` + + Alternatively, consumers can simply add an `iconElement` property of `ReactElement` type to the tool class. + + ```tsx + export class MyTool extends Tool { + public static iconSpec = "icon-placeholder"; + public static iconElement = (); + } + ``` + + > [!NOTE] + > Newly defined `iconElement` property needs to be read by the consumers to display the icon in a toolbar, unless the `ToolbarItemUtilities.createForTool` helper is used when creating toolbar items. + + - `ToolUtilities.isWithIcon` function is a type guard that checks if a tool has a React icon element defined. Which is useful to read the icon element from the tool type. + + ```tsx + if (ToolUtilities.isWithIcon(MyTool)) { + MyTool.iconElement; // ReactElement + } + ``` diff --git a/e2e-tests/tests/toolbar/toolbar-composer.test.ts-snapshots/toolbar-composer-test-1-chromium-linux.png b/e2e-tests/tests/toolbar/toolbar-composer.test.ts-snapshots/toolbar-composer-test-1-chromium-linux.png index 94360cae7d1c9b15901f43ed8654a161cbf8f6ae..ffcc57dafeda994f7712630ce763fca9afcc4111 100644 GIT binary patch literal 3518 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 diff --git a/ui/appui-react/src/appui-react/icons/SvgViewLayouts.tsx b/ui/appui-react/src/appui-react/icons/SvgViewLayouts.tsx new file mode 100644 index 00000000000..7ec3365e040 --- /dev/null +++ b/ui/appui-react/src/appui-react/icons/SvgViewLayouts.tsx @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +/** @packageDocumentation + * @module Utilities + */ + +import * as React from "react"; + +/** @internal */ +export function SvgViewLayouts() { + return ( + + + + ); +} diff --git a/ui/appui-react/src/appui-react/toolbar/ToolbarItemUtilities.tsx b/ui/appui-react/src/appui-react/toolbar/ToolbarItemUtilities.tsx index da3b57fb54e..1ae15f2395b 100644 --- a/ui/appui-react/src/appui-react/toolbar/ToolbarItemUtilities.tsx +++ b/ui/appui-react/src/appui-react/toolbar/ToolbarItemUtilities.tsx @@ -14,6 +14,7 @@ import type { ToolbarGroupItem, } from "./ToolbarItem.js"; import { isArgsUtil } from "../backstage/BackstageItemUtilities.js"; +import { ToolUtilities } from "@itwin/imodel-components-react"; /** Helper namespace to create toolbar items. * @public @@ -221,6 +222,10 @@ export namespace ToolbarItemUtilities { toolType: ToolType, overrides?: Partial ): ToolbarActionItem { + const iconNode = ToolUtilities.isWithIcon(toolType) + ? toolType.iconElement + : undefined; + // eslint-disable-next-line @typescript-eslint/no-deprecated return ToolbarItemUtilities.createActionItem( toolType.toolId, @@ -232,6 +237,7 @@ export namespace ToolbarItemUtilities { }, { description: toolType.description, + iconNode, ...overrides, } ); diff --git a/ui/appui-react/src/appui-react/tools/KeyinPaletteTools.ts b/ui/appui-react/src/appui-react/tools/KeyinPaletteTools.tsx similarity index 70% rename from ui/appui-react/src/appui-react/tools/KeyinPaletteTools.ts rename to ui/appui-react/src/appui-react/tools/KeyinPaletteTools.tsx index 5fd1681412b..75960801de8 100644 --- a/ui/appui-react/src/appui-react/tools/KeyinPaletteTools.ts +++ b/ui/appui-react/src/appui-react/tools/KeyinPaletteTools.tsx @@ -5,19 +5,15 @@ /** @packageDocumentation * @module Tools */ - +import * as React from "react"; import { clearKeyinPaletteHistory } from "../popup/KeyinPalettePanel.js"; import { Tool } from "@itwin/core-frontend"; -import svgRemove from "@bentley/icons-generic/icons/remove.svg"; +import { ToolUtilities } from "@itwin/imodel-components-react"; +import { SvgRemove } from "@itwin/itwinui-icons-react"; -/** - * Immediate tool that will clear the recent history of command/tool keyins shown in - * the command palette. - * @alpha - */ -export class ClearKeyinPaletteHistoryTool extends Tool { +class ClearKeyinPaletteHistoryCoreTool extends Tool { public static override toolId = "ClearKeyinPaletteHistory"; - public static override iconSpec = svgRemove; + public static override iconSpec = "icon-remove"; public static override get minArgs() { return 0; @@ -31,3 +27,13 @@ export class ClearKeyinPaletteHistoryTool extends Tool { return true; } } + +/** + * Immediate tool that will clear the recent history of command/tool keyins shown in + * the command palette. + * @alpha + */ +export const ClearKeyinPaletteHistoryTool = ToolUtilities.defineIcon( + ClearKeyinPaletteHistoryCoreTool, + +); diff --git a/ui/appui-react/src/appui-react/tools/OpenSettingsTool.ts b/ui/appui-react/src/appui-react/tools/OpenSettingsTool.tsx similarity index 74% rename from ui/appui-react/src/appui-react/tools/OpenSettingsTool.ts rename to ui/appui-react/src/appui-react/tools/OpenSettingsTool.tsx index 7632df28e11..a419e8a5569 100644 --- a/ui/appui-react/src/appui-react/tools/OpenSettingsTool.ts +++ b/ui/appui-react/src/appui-react/tools/OpenSettingsTool.tsx @@ -5,17 +5,15 @@ /** @packageDocumentation * @module Tools */ +import * as React from "react"; import { Tool } from "@itwin/core-frontend"; +import { ToolUtilities } from "@itwin/imodel-components-react"; +import { SvgSettings } from "@itwin/itwinui-icons-react"; import { SettingsModalFrontstage } from "../frontstage/ModalSettingsStage.js"; -import svgSettings from "@bentley/icons-generic/icons/settings.svg"; -/** - * Immediate tool that will open the Settings modal stage. - * @alpha - */ -export class OpenSettingsTool extends Tool { +class OpenSettingsCoreTool extends Tool { public static override toolId = "OpenSettings"; - public static override iconSpec = svgSettings; + public static override iconSpec = "icon-settings"; public static override get minArgs() { return 0; @@ -33,3 +31,12 @@ export class OpenSettingsTool extends Tool { return this.run(args[0]); } } + +/** + * Immediate tool that will open the Settings modal stage. + * @alpha + */ +export const OpenSettingsTool = ToolUtilities.defineIcon( + OpenSettingsCoreTool, + +); diff --git a/ui/appui-react/src/appui-react/tools/RestoreLayoutTool.ts b/ui/appui-react/src/appui-react/tools/RestoreLayoutTool.tsx similarity index 76% rename from ui/appui-react/src/appui-react/tools/RestoreLayoutTool.ts rename to ui/appui-react/src/appui-react/tools/RestoreLayoutTool.tsx index 7617584eef9..f51d2963ba5 100644 --- a/ui/appui-react/src/appui-react/tools/RestoreLayoutTool.ts +++ b/ui/appui-react/src/appui-react/tools/RestoreLayoutTool.tsx @@ -5,6 +5,7 @@ /** @packageDocumentation * @module Tools */ +import * as React from "react"; import { IModelApp, NotifyMessageDetails, @@ -14,16 +15,12 @@ import { import type { FrontstageDef } from "../frontstage/FrontstageDef.js"; import { InternalFrontstageManager } from "../frontstage/InternalFrontstageManager.js"; import { UiFramework } from "../UiFramework.js"; -import svgViewLayouts from "@bentley/icons-generic/icons/view-layouts.svg"; +import { SvgViewLayouts } from "../icons/SvgViewLayouts.js"; +import { ToolUtilities } from "@itwin/imodel-components-react"; -/** - * Immediate tool that will reset the layout to that specified in the stage definition. A stage Id - * may be passed in, if not the active stage is used. The stage Id is case sensitive. - * @public - */ -export class RestoreFrontstageLayoutTool extends Tool { +class RestoreFrontstageLayoutCoreTool extends Tool { public static override toolId = "RestoreFrontstageLayout"; - public static override iconSpec = svgViewLayouts; + public static override iconSpec = "icon-view-layouts"; public static override get minArgs() { return 0; @@ -57,15 +54,25 @@ export class RestoreFrontstageLayoutTool extends Tool { public override async parseAndRun(...args: string[]): Promise { return this.run(args[0]); } + + public getIconNode() { + return ; + } } /** - * Immediate tool that will reset the layout of all frontstages to that specified in the stage definition. + * Immediate tool that will reset the layout to that specified in the stage definition. A stage Id + * may be passed in, if not the active stage is used. The stage Id is case sensitive. * @public */ -export class RestoreAllFrontstagesTool extends Tool { +export const RestoreFrontstageLayoutTool = ToolUtilities.defineIcon( + RestoreFrontstageLayoutCoreTool, + +); + +class RestoreAllFrontstagesCoreTool extends Tool { public static override toolId = "RestoreAllFrontstages"; - public static override iconSpec = svgViewLayouts; + public static override iconSpec = "icon-view-layouts"; public override async run() { const frontstages = InternalFrontstageManager.frontstageDefs; @@ -75,3 +82,12 @@ export class RestoreAllFrontstagesTool extends Tool { return true; } } + +/** + * Immediate tool that will reset the layout of all frontstages to that specified in the stage definition. + * @public + */ +export const RestoreAllFrontstagesTool = ToolUtilities.defineIcon( + RestoreAllFrontstagesCoreTool, + +); diff --git a/ui/appui-react/src/test/toolbar/ToolbarItemUtilities.test.tsx b/ui/appui-react/src/test/toolbar/ToolbarItemUtilities.test.tsx new file mode 100644 index 00000000000..cec63013617 --- /dev/null +++ b/ui/appui-react/src/test/toolbar/ToolbarItemUtilities.test.tsx @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +import * as React from "react"; +import { Tool } from "@itwin/core-frontend"; +import { ToolbarItemUtilities } from "../../appui-react.js"; +import { ToolUtilities } from "@itwin/imodel-components-react"; +import { render } from "@testing-library/react"; + +describe("ToolbarItemUtilities.createForTool", () => { + it("should read `iconElement` property", () => { + class MyTool extends Tool { + public static override iconSpec = "icon-placeholder"; + } + ToolUtilities.defineIcon(MyTool, My SVG); + + const item = ToolbarItemUtilities.createForTool(MyTool); + expect(item.icon).toEqual("icon-placeholder"); + + const { getByText } = render(item.iconNode); + getByText("My SVG"); + }); +}); diff --git a/ui/imodel-components-react/src/imodel-components-react.ts b/ui/imodel-components-react/src/imodel-components-react.ts index de93474b14e..527e16d0c9b 100644 --- a/ui/imodel-components-react/src/imodel-components-react.ts +++ b/ui/imodel-components-react/src/imodel-components-react.ts @@ -157,6 +157,8 @@ export { ViewRotationChangeEventArgs, } from "./imodel-components-react/viewport/ViewportComponentEvents.js"; +export { ToolUtilities } from "./imodel-components-react/ToolUtilities.js"; + // #region "SideEffects" import { StandardEditorNames, StandardTypeNames } from "@itwin/appui-abstract"; diff --git a/ui/imodel-components-react/src/imodel-components-react/ToolUtilities.ts b/ui/imodel-components-react/src/imodel-components-react/ToolUtilities.ts new file mode 100644 index 00000000000..f7464b28a2c --- /dev/null +++ b/ui/imodel-components-react/src/imodel-components-react/ToolUtilities.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +/** @packageDocumentation + * @module Common + */ + +import * as React from "react"; +import type { ToolType } from "@itwin/core-frontend"; + +/** A {@link @itwin/core-frontend#ToolType} with an icon element specified as a React element. */ +type ToolWithIcon = T & { + iconElement: React.ReactElement; +}; + +/** Utilities related to {@link @itwin/core-frontend#Tool} class. + * @public + */ +export namespace ToolUtilities { + /** + * Defines an icon property for a specified {@link @itwin/core-frontend#ToolType}. + * + * ```tsx + * ToolUtilities.defineIcon(MyTool, ); + * ``` + * + * Alternatively, consumers can define the `iconElement` property directly on the tool class. + * ```tsx + * class MyTool extends Tool { + * public static iconElement = ; + * } + * ``` + */ + export function defineIcon( + toolType: T, + iconElement: React.ReactElement + ): ToolWithIcon { + const withIcon = toolType as unknown as ToolWithIcon; + withIcon.iconElement = iconElement; + return withIcon; + } + + /** Type guard for a {@link @itwin/core-frontend#ToolType} with an `iconElement` property. */ + export function isWithIcon( + toolType: T + ): toolType is ToolWithIcon { + return ( + "iconElement" in toolType && React.isValidElement(toolType.iconElement) + ); + } +} diff --git a/ui/imodel-components-react/src/test/ToolUtilities.test.tsx b/ui/imodel-components-react/src/test/ToolUtilities.test.tsx new file mode 100644 index 00000000000..5eb727a64a8 --- /dev/null +++ b/ui/imodel-components-react/src/test/ToolUtilities.test.tsx @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +import * as React from "react"; +import { Tool } from "@itwin/core-frontend"; +import { render } from "@testing-library/react"; +import { ToolUtilities } from "../imodel-components-react.js"; + +describe("ToolUtilities.defineIcon", () => { + it("should define an icon property for a specified ToolType", () => { + class MyTool extends Tool {} + + const ToolWithIcon = ToolUtilities.defineIcon(MyTool, My SVG); + expect(ToolWithIcon.iconElement).toBeDefined(); + expect(MyTool).toHaveProperty("iconElement"); + + const { getByText } = render(ToolWithIcon.iconElement); + getByText("My SVG"); + }); + + it("should override `iconElement`", () => { + class MyTool extends Tool {} + + const ToolWithIcon = ToolUtilities.defineIcon(MyTool, My SVG); + + class MyNewTool extends ToolWithIcon { + public static override iconElement = (My new SVG); + } + + const { getByText } = render(MyNewTool.iconElement); + getByText("My new SVG"); + }); + + it("should create an instance from a newly returned type", () => { + class MyTool extends Tool { + public test() {} + } + const spy = vi.spyOn(MyTool.prototype, "test"); + + const ToolWithIcon = ToolUtilities.defineIcon(MyTool, My SVG); + new ToolWithIcon().test(); + expect(spy).toHaveBeenCalledOnce(); + }); +}); + +describe("ToolUtilities.isWithIcon", () => { + it("static `iconElement` property", () => { + expect(ToolUtilities.isWithIcon(Tool)).toBe(false); + + class MyTool extends Tool { + public static iconElement = (My SVG); + } + expect(ToolUtilities.isWithIcon(MyTool)).toBe(true); + + class ToolWithIncorrectType extends Tool { + public static iconElement = "icon-placeholder"; + } + expect(ToolUtilities.isWithIcon(ToolWithIncorrectType)).toBe(false); + }); + + it("`defineIcon` helper", () => { + class MyTool extends Tool {} + expect(ToolUtilities.isWithIcon(MyTool)).toBe(false); + + const ToolWithIcon = ToolUtilities.defineIcon(MyTool, My SVG); + expect(ToolUtilities.isWithIcon(MyTool)).toBe(true); + expect(ToolUtilities.isWithIcon(ToolWithIcon)).toBe(true); + }); +});