diff --git a/packages/calcite-components/src/components/pick-list-group/pick-list-group.tsx b/packages/calcite-components/src/components/pick-list-group/pick-list-group.tsx index c76c1ea857a..bd0181bbfc3 100644 --- a/packages/calcite-components/src/components/pick-list-group/pick-list-group.tsx +++ b/packages/calcite-components/src/components/pick-list-group/pick-list-group.tsx @@ -6,8 +6,15 @@ import { } from "../../utils/conditionalSlot"; import { getSlotted } from "../../utils/dom"; import { constrainHeadingLevel, Heading, HeadingLevel } from "../functional/Heading"; +import { logger } from "../../utils/logger"; import { CSS, SLOTS } from "./resources"; +logger.deprecated("component", { + name: "pick-list-group", + removalVersion: 3, + suggested: "list-item-group", +}); + /** * @deprecated Use the `calcite-list` component instead. * @slot - A slot for adding `calcite-pick-list-item` elements. diff --git a/packages/calcite-components/src/components/pick-list-item/pick-list-item.tsx b/packages/calcite-components/src/components/pick-list-item/pick-list-item.tsx index 92735917fec..b7a2b342fdf 100644 --- a/packages/calcite-components/src/components/pick-list-item/pick-list-item.tsx +++ b/packages/calcite-components/src/components/pick-list-item/pick-list-item.tsx @@ -38,9 +38,16 @@ import { updateMessages, } from "../../utils/t9n"; import { ICON_TYPES } from "../pick-list/resources"; +import { logger } from "../../utils/logger"; import { PickListItemMessages } from "./assets/pick-list-item/t9n"; import { CSS, ICONS, SLOTS } from "./resources"; +logger.deprecated("component", { + name: "pick-list", + removalVersion: 3, + suggested: "list", +}); + /** * @deprecated Use the `calcite-list` component instead. * @slot actions-end - A slot for adding `calcite-action`s or content to the end side of the component. diff --git a/packages/calcite-components/src/components/pick-list/pick-list.tsx b/packages/calcite-components/src/components/pick-list/pick-list.tsx index 5d9667c8d59..7c29fa7d8e8 100644 --- a/packages/calcite-components/src/components/pick-list/pick-list.tsx +++ b/packages/calcite-components/src/components/pick-list/pick-list.tsx @@ -25,6 +25,7 @@ import { import { createObserver } from "../../utils/observers"; import { HeadingLevel } from "../functional/Heading"; import type { ValueUnion } from "../types"; +import { logger } from "../../utils/logger"; import { ICON_TYPES } from "./resources"; import { calciteInternalListItemValueChangeHandler, @@ -50,6 +51,12 @@ import { } from "./shared-list-logic"; import List from "./shared-list-render"; +logger.deprecated("component", { + name: "pick-list-item", + removalVersion: 3, + suggested: "list-item", +}); + /** * @deprecated Use the `calcite-list` component instead. * @slot - A slot for adding `calcite-pick-list-item` or `calcite-pick-list-group` elements. Items are displayed as a vertical list. diff --git a/packages/calcite-components/src/components/tile-select-group/tile-select-group.tsx b/packages/calcite-components/src/components/tile-select-group/tile-select-group.tsx index 6ca4f1e3540..6140b4b779d 100644 --- a/packages/calcite-components/src/components/tile-select-group/tile-select-group.tsx +++ b/packages/calcite-components/src/components/tile-select-group/tile-select-group.tsx @@ -6,8 +6,15 @@ import { InteractiveContainer, updateHostInteraction, } from "../../utils/interactive"; +import { logger } from "../../utils/logger"; import { TileSelectGroupLayout } from "./interfaces"; +logger.deprecated("component", { + name: "tile-select-group", + removalVersion: 4, + suggested: ["tile", "tile-group"], +}); + /** * @deprecated Use the `calcite-tile-group` component instead. * @slot - A slot for adding `calcite-tile-select` elements. diff --git a/packages/calcite-components/src/components/tile-select/tile-select.tsx b/packages/calcite-components/src/components/tile-select/tile-select.tsx index f4501318f28..3caa80856a5 100644 --- a/packages/calcite-components/src/components/tile-select/tile-select.tsx +++ b/packages/calcite-components/src/components/tile-select/tile-select.tsx @@ -27,9 +27,16 @@ import { } from "../../utils/loadable"; import { Alignment, Width } from "../interfaces"; import { IconName } from "../icon/interfaces"; +import { logger } from "../../utils/logger"; import { TileSelectType } from "./interfaces"; import { CSS } from "./resources"; +logger.deprecated("component", { + name: "tile-select", + removalVersion: 4, + suggested: ["tile", "tile-group"], +}); + /** * @deprecated Use the `calcite-tile` component instead. * @slot - A slot for adding custom content. diff --git a/packages/calcite-components/src/components/tip-group/tip-group.tsx b/packages/calcite-components/src/components/tip-group/tip-group.tsx index 9c785a339c4..886b10ba657 100644 --- a/packages/calcite-components/src/components/tip-group/tip-group.tsx +++ b/packages/calcite-components/src/components/tip-group/tip-group.tsx @@ -1,4 +1,11 @@ import { Component, h, Prop, VNode } from "@stencil/core"; +import { logger } from "../../utils/logger"; + +logger.deprecated("component", { + name: "tip-group", + removalVersion: 4, + suggested: ["carousel", "carousel-item"], +}); /** * @deprecated Use the `calcite-carousel` and `calcite-carousel-item` components instead. diff --git a/packages/calcite-components/src/components/tip-manager/tip-manager.tsx b/packages/calcite-components/src/components/tip-manager/tip-manager.tsx index 6df1efea907..5da31cb5c96 100644 --- a/packages/calcite-components/src/components/tip-manager/tip-manager.tsx +++ b/packages/calcite-components/src/components/tip-manager/tip-manager.tsx @@ -20,9 +20,16 @@ import { updateMessages, } from "../../utils/t9n"; import { Heading, HeadingLevel } from "../functional/Heading"; +import { logger } from "../../utils/logger"; import { TipManagerMessages } from "./assets/tip-manager/t9n"; import { CSS, ICONS } from "./resources"; +logger.deprecated("component", { + name: "tip-manager", + removalVersion: 4, + suggested: "carousel", +}); + /** * @deprecated Use the `calcite-carousel` and `calcite-carousel-item` components instead. * @slot - A slot for adding `calcite-tip`s. diff --git a/packages/calcite-components/src/components/tip/tip.tsx b/packages/calcite-components/src/components/tip/tip.tsx index e952d422709..7a9398cf589 100644 --- a/packages/calcite-components/src/components/tip/tip.tsx +++ b/packages/calcite-components/src/components/tip/tip.tsx @@ -25,9 +25,16 @@ import { updateMessages, } from "../../utils/t9n"; import { constrainHeadingLevel, Heading, HeadingLevel } from "../functional/Heading"; +import { logger } from "../../utils/logger"; import { TipMessages } from "./assets/tip/t9n"; import { CSS, ICONS, SLOTS } from "./resources"; +logger.deprecated("component", { + name: "tip", + removalVersion: 4, + suggested: ["card", "notice", "panel", "tile"], +}); + /** * @deprecated Use the `calcite-card`, `calcite-notice`, `calcite-panel`, or `calcite-tile` component instead. * @slot - A slot for adding text and a hyperlink. diff --git a/packages/calcite-components/src/components/value-list/value-list.tsx b/packages/calcite-components/src/components/value-list/value-list.tsx index 75041334b4d..7b3240cced6 100644 --- a/packages/calcite-components/src/components/value-list/value-list.tsx +++ b/packages/calcite-components/src/components/value-list/value-list.tsx @@ -65,10 +65,17 @@ import { SortableComponent, } from "../../utils/sortableComponent"; import { focusElement } from "../../utils/dom"; +import { logger } from "../../utils/logger"; import { ValueListMessages } from "./assets/value-list/t9n"; import { CSS, ICON_TYPES } from "./resources"; import { getHandleAndItemElement, getScreenReaderText } from "./utils"; +logger.deprecated("component", { + name: "value-list", + removalVersion: 3, + suggested: "list", +}); + /** * @deprecated Use the `calcite-list` component instead. * @slot - A slot for adding `calcite-value-list-item` elements. List items are displayed as a vertical list. diff --git a/packages/calcite-components/src/tests/setupTests.ts b/packages/calcite-components/src/tests/setupTests.ts index cf9ae4f3c09..92e1ca31348 100644 --- a/packages/calcite-components/src/tests/setupTests.ts +++ b/packages/calcite-components/src/tests/setupTests.ts @@ -1,14 +1,14 @@ let globalError: jest.SpyInstance; -let globalLog: jest.SpyInstance; +let globalInfo: jest.SpyInstance; beforeAll(() => { globalError = jest.spyOn(global.console, "error"); - globalLog = jest.spyOn(global.console, "info").mockImplementation(() => null); + globalInfo = jest.spyOn(global.console, "info").mockImplementation(() => null); }); beforeEach(() => { globalError.mockClear(); - globalLog.mockClear(); + globalInfo.mockClear(); }); // eslint-disable-next-line jest/no-standalone-expect @@ -16,5 +16,5 @@ afterEach(() => expect(globalError).not.toHaveBeenCalled()); afterAll(() => { globalError.mockRestore(); - globalLog.mockRestore(); + globalInfo.mockRestore(); }); diff --git a/packages/calcite-components/src/utils/config.spec.ts b/packages/calcite-components/src/utils/config.spec.ts index 8b43721e749..a3917c5079a 100644 --- a/packages/calcite-components/src/utils/config.spec.ts +++ b/packages/calcite-components/src/utils/config.spec.ts @@ -13,6 +13,7 @@ describe("config", () => { it("has defaults", async () => { config = await loadConfig(); expect(config.focusTrapStack).toHaveLength(0); + expect(config.logLevel).toBe("info"); }); it("allows custom configuration", async () => { diff --git a/packages/calcite-components/src/utils/config.ts b/packages/calcite-components/src/utils/config.ts index 988a5250a0d..7f04c69a503 100644 --- a/packages/calcite-components/src/utils/config.ts +++ b/packages/calcite-components/src/utils/config.ts @@ -3,6 +3,7 @@ */ import { FocusTrap } from "./focusTrapComponent"; +import { LogLevel } from "./logger"; export interface CalciteConfig { /** @@ -14,6 +15,11 @@ export interface CalciteConfig { */ focusTrapStack: FocusTrap[]; + /** + * Defines the global log level to use when logging messages. + */ + logLevel: LogLevel; + /** * Contains the version of the Calcite components. * @@ -26,6 +32,8 @@ const existingConfig: CalciteConfig = globalThis["calciteConfig"]; export const focusTrapStack: FocusTrap[] = existingConfig?.focusTrapStack || []; +export const logLevel: LogLevel = existingConfig?.logLevel || "info"; + // the following placeholders are replaced by the build const version = "__CALCITE_VERSION__"; const buildDate = "__CALCITE_BUILD_DATE__"; diff --git a/packages/calcite-components/src/utils/focusTrapComponent.spec.ts b/packages/calcite-components/src/utils/focusTrapComponent.spec.ts index f98958b408f..b440d91b421 100644 --- a/packages/calcite-components/src/utils/focusTrapComponent.spec.ts +++ b/packages/calcite-components/src/utils/focusTrapComponent.spec.ts @@ -72,7 +72,7 @@ describe("focusTrapComponent", () => { window = win as any as Window & typeof globalThis; globalThis.MutationObserver = window.MutationObserver; // needed for focus-trap - type TestGlobal = GlobalTestProps<{ calciteConfig: CalciteConfig }>; + type TestGlobal = GlobalTestProps<{ calciteConfig: Pick }>; (globalThis as TestGlobal).calciteConfig = { focusTrapStack: customFocusTrapStack, diff --git a/packages/calcite-components/src/utils/logger.spec.ts b/packages/calcite-components/src/utils/logger.spec.ts new file mode 100644 index 00000000000..6c755979834 --- /dev/null +++ b/packages/calcite-components/src/utils/logger.spec.ts @@ -0,0 +1,188 @@ +import { GlobalTestProps } from "../tests/utils"; +import { LogLevel } from "./logger"; +import { CalciteConfig } from "./config"; + +describe("logger", () => { + type LoggerModule = typeof import("./logger"); + + let loggerModule: LoggerModule; + let logger: LoggerModule["logger"]; + + let debugSpy: jest.SpyInstance; + let errorSpy: jest.SpyInstance; + let infoSpy: jest.SpyInstance; + let traceSpy: jest.SpyInstance; + let warnSpy: jest.SpyInstance; + + beforeEach(async () => { + const noop = () => { + /* intentional noop */ + }; + + debugSpy = jest.spyOn(console, "debug").mockImplementation(noop); + errorSpy = jest.spyOn(console, "error").mockImplementation(noop); + infoSpy = jest.spyOn(console, "info").mockImplementation(noop); + traceSpy = jest.spyOn(console, "trace").mockImplementation(noop); + warnSpy = jest.spyOn(console, "warn").mockImplementation(noop); + + jest.resetModules(); + loggerModule = await import("./logger"); + logger = loggerModule.logger; + loggerModule.loggedDeprecations.clear(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("deprecated", () => { + it("helps log planned deprecations", () => { + const params = { + name: "foo", + removalVersion: 3, + }; + + // @ts-expect-error -- using fake component names + logger.deprecated("component", params); + + expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy.mock.calls[0][2]).toMatch( + `[${params.name}] component is deprecated and will be removed in v${params.removalVersion}.`, + ); + }); + + it("helps log future deprecations", () => { + const options = { + name: "foo", + removalVersion: "future", + }; + + // @ts-expect-error -- using fake component names + logger.deprecated("component", options); + + expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy.mock.calls[0][2]).toMatch( + `[${options.name}] component is deprecated and will be removed in a future version.`, + ); + }); + + it("shows deprecation suggestions (single)", () => { + const params = { + name: "foo", + removalVersion: 3, + suggested: "bar", + }; + + // @ts-expect-error -- using fake component names + logger.deprecated("component", params); + + expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy.mock.calls[0][2]).toMatch( + `[${params.name}] component is deprecated and will be removed in v${params.removalVersion}. Use "${params.suggested}" instead.`, + ); + }); + + it("shows deprecation suggestions (multiple)", () => { + const params = { + name: "foo", + removalVersion: 3, + suggested: ["bar", "baz"], + }; + + // @ts-expect-error -- using fake component names + logger.deprecated("component", params); + + expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy.mock.calls[0][2]).toMatch( + `[${params.name}] component is deprecated and will be removed in v${params.removalVersion}. Use "${params.suggested.join(`" or "`)}" instead.`, + ); + }); + + it("logs once per component", () => { + const params = { + name: "foo", + removalVersion: 3, + }; + + // @ts-expect-error -- using fake component names + logger.deprecated("component", params); + // @ts-expect-error -- using fake component names + logger.deprecated("component", params); + + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe("logLevel", () => { + type TestGlobal = GlobalTestProps<{ calciteConfig: Pick }>; + + function messageAllLevels(): void { + const levels = ["debug", "info", "warn", "error", "trace"] as const; + + levels.forEach((level) => logger[level]("message")); + } + + async function setLogLevel(level: LogLevel): Promise { + jest.resetModules(); + + (globalThis as TestGlobal).calciteConfig = { + logLevel: level, + }; + + loggerModule = await import("./logger"); + logger = loggerModule.logger; + } + + afterEach(() => { + delete (globalThis as TestGlobal).calciteConfig; + }); + + it("logs all messages when set to lowest level", async () => { + await setLogLevel("trace"); + + messageAllLevels(); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(infoSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(debugSpy).toHaveBeenCalledTimes(1); + expect(traceSpy).toHaveBeenCalledTimes(1); + }); + + it("logs only error messages when set to highest level", async () => { + await setLogLevel("error"); + + messageAllLevels(); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(infoSpy).toHaveBeenCalledTimes(0); + expect(warnSpy).toHaveBeenCalledTimes(0); + expect(debugSpy).toHaveBeenCalledTimes(0); + expect(traceSpy).toHaveBeenCalledTimes(0); + }); + + it("logs info messages and above when set to default level", async () => { + await setLogLevel("info"); + + messageAllLevels(); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(infoSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(debugSpy).toHaveBeenCalledTimes(0); + expect(traceSpy).toHaveBeenCalledTimes(0); + }); + + it("logs no messages when set to `off`", async () => { + await setLogLevel("off"); + + messageAllLevels(); + + expect(debugSpy).toHaveBeenCalledTimes(0); + expect(errorSpy).toHaveBeenCalledTimes(0); + expect(infoSpy).toHaveBeenCalledTimes(0); + expect(traceSpy).toHaveBeenCalledTimes(0); + expect(warnSpy).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/packages/calcite-components/src/utils/logger.ts b/packages/calcite-components/src/utils/logger.ts new file mode 100644 index 00000000000..e287b890a0c --- /dev/null +++ b/packages/calcite-components/src/utils/logger.ts @@ -0,0 +1,94 @@ +import type { JSX } from "../components"; +import { logLevel } from "./config"; + +export type LogLevel = "debug" | "info" | "warn" | "error" | "trace" | "off"; + +type Message = string; +type MajorVersion = number; + +type DeprecatedContext = "component" | "property" | "method" | "event" | "slot"; + +type DeprecatedParams = { + name: string; + suggested?: string | string[]; + component?: string; + removalVersion: MajorVersion | "future"; +}; + +type SimpleComponentName = T extends `calcite-${infer Name}` ? Name : T; + +type ComponentDeprecatedParams = Omit & { + name: SimpleComponentName; +}; + +/** + * Exported for testing purposes only + */ +export const loggedDeprecations = new Set(); + +const logLevels = { + trace: 0, + debug: 1, + info: 2, + warn: 4, + error: 8, + off: 10, +} as const; + +function willLog(level: LogLevel): boolean { + return logLevels[level] >= logLevels[logLevel]; +} + +function forwardToConsole(level: LogLevel, ...data: any[]): void { + if (!willLog(level)) { + return; + } + + const badgeTemplate = "%ccalcite"; + const badgeStyle = "background: #007AC2; color: #fff; border-radius: 4px; padding: 2px 4px;"; + + console[level].call(this, badgeTemplate, badgeStyle, ...data); +} + +let listFormatter: Intl.ListFormat; + +export const logger = { + debug: (message: Message) => forwardToConsole("debug", message), + info: (message: Message) => forwardToConsole("info", message), + warn: (message: Message) => forwardToConsole("warn", message), + error: (message: Message) => forwardToConsole("error", message), + trace: (message: Message) => forwardToConsole("trace", message), + + deprecated, +} as const; + +/** + * Logs a deprecation warning to the console. + * + * @param context the context in which the deprecation is occurring + * @param params the deprecation details + */ +function deprecated(context: Exclude, params: DeprecatedParams): void; +function deprecated(context: Extract, params: ComponentDeprecatedParams): void; +function deprecated( + context: DeprecatedContext, + { component, name, suggested, removalVersion }: DeprecatedParams | ComponentDeprecatedParams, +): void { + const key = `${context}:${context === "component" ? "" : component}${name}`; + + if (loggedDeprecations.has(key)) { + return; + } + + loggedDeprecations.add(key); + + const multiSuggestions = Array.isArray(suggested); + + if (multiSuggestions && !listFormatter) { + listFormatter = new Intl.ListFormat("en", { style: "long", type: "disjunction" }); + } + + const message = `[${name}] ${context} is deprecated and will be removed in ${removalVersion === "future" ? `a future version` : `v${removalVersion}`}.${suggested ? ` Use ${multiSuggestions ? listFormatter.format(suggested.map((suggestion) => `"${suggestion}"`)) : `"${suggested}"`} instead.` : ""}`; + + forwardToConsole("warn", message); +}