From 865fc0125ce8fc7e868025a3e87fbf5b693a3227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 8 Apr 2024 23:23:06 +0100 Subject: [PATCH] web: Add initial version of core/Field A component to help laying out interactive information in pages. --- web/src/components/core/Field.jsx | 107 +++++++++++++++++++++++ web/src/components/core/Field.test.jsx | 112 +++++++++++++++++++++++++ web/src/components/core/index.js | 2 + web/src/components/layout/Icon.jsx | 8 ++ 4 files changed, 229 insertions(+) create mode 100644 web/src/components/core/Field.jsx create mode 100644 web/src/components/core/Field.test.jsx diff --git a/web/src/components/core/Field.jsx b/web/src/components/core/Field.jsx new file mode 100644 index 0000000000..461a52ca22 --- /dev/null +++ b/web/src/components/core/Field.jsx @@ -0,0 +1,107 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +// @ts-check + +import React from "react"; +import { Icon } from "~/components/layout"; +import { If } from "~/components/core"; + +/** + * @typedef {import("~/components/layout/Icon").IconName} IconName + * @typedef {import("~/components/layout/Icon").IconSize} IconSize + */ + +/** + * @typedef {object} FieldProps + * @property {React.ReactNode} label - The field label. + * @property {React.ReactNode} [value] - The field value. + * @property {React.ReactNode} [description] - A field description, useful for providing context to the user. + * @property {IconName} [icon] - The name of the icon for the field. + * @property {IconSize} [iconSize] - The size for the field icon. + * @property {string} [className] - ClassName + * @property {() => {}} [onClick] - Callback + * @property {React.ReactNode} [children] - A content to be rendered as field children + * + * @typedef {Omit} FieldPropsWithoutIcon + */ + +/** + * Component for laying out a page field + * + * @param {FieldProps} props + */ +const Field = ({ + label, + value, + description, + icon, + iconSize, + onClick, + children, + ...props +}) => { + return ( +
+
+ {value} +
+
+ {description} +
+
+ { children } +
+
+ ); +}; + +/** + * @param {Omit} props + */ +const SettingsField = ({ ...props }) => { + return ; +}; + +/** + * @param {Omit & {isChecked: true}} props + */ +const SwitchField = ({ isChecked, ...props }) => { + const iconName = isChecked ? "toggle_on" : "toggle_off"; + const className = isChecked ? "on" : "off"; + + return ; +}; + +/** + * @param {Omit & {isExpanded: boolean}} props + */ +const ExpandableField = ({ isExpanded, ...props }) => { + const iconName = isExpanded ? "collapse_all" : "expand_all"; + const className = isExpanded ? "expanded" : "collapsed"; + + return ; +}; + +export default Field; +export { ExpandableField, SettingsField, SwitchField }; diff --git a/web/src/components/core/Field.test.jsx b/web/src/components/core/Field.test.jsx new file mode 100644 index 0000000000..ffaae06651 --- /dev/null +++ b/web/src/components/core/Field.test.jsx @@ -0,0 +1,112 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { Field, ExpandableField, SettingsField, SwitchField } from "~/components/core"; + +const onClick = jest.fn(); + +describe("Field", () => { + it("renders a button with given icon and label", () => { + const { container } = plainRender( + + ); + screen.getByRole("button", { name: "Theme" }); + const icon = container.querySelector("button > svg"); + expect(icon).toHaveAttribute("data-icon-name", "edit"); + }); + + it("renders value, description, and given children", () => { + plainRender( + +

This is a preview

; +
+ ); + screen.getByText("dark"); + screen.getByText("Choose your preferred color schema."); + screen.getByText("This is a"); + screen.getByText("preview"); + }); + + it("triggers the onClick callback when users clicks the button", async () => { + const { user } = plainRender( + + ); + const button = screen.getByRole("button"); + await user.click(button); + expect(onClick).toHaveBeenCalled(); + }); +}); + +describe("SettingsField", () => { + it("uses the 'settings' icon", () => { + const { container } = plainRender( + // Trying to set other icon, although typechecking should catch it. + + ); + const icon = container.querySelector("button > svg"); + expect(icon).toHaveAttribute("data-icon-name", "settings"); + }); +}); + +describe("SwitchField", () => { + it("uses the 'toggle_on' icon when isChecked", () => { + const { container } = plainRender( + + ); + const icon = container.querySelector("button > svg"); + expect(icon).toHaveAttribute("data-icon-name", "toggle_on"); + }); + + it("uses the 'toggle_off' icon when not isChecked", () => { + const { container } = plainRender( + + ); + const icon = container.querySelector("button > svg"); + expect(icon).toHaveAttribute("data-icon-name", "toggle_off"); + }); +}); + +describe("ExpandableField", () => { + it("uses the 'collapse_all' icon when isExpanded", () => { + const { container } = plainRender( + + ); + const icon = container.querySelector("button > svg"); + expect(icon).toHaveAttribute("data-icon-name", "collapse_all"); + }); + + it("uses the 'expand_all' icon when not isExpanded", () => { + const { container } = plainRender( + + ); + const icon = container.querySelector("button > svg"); + expect(icon).toHaveAttribute("data-icon-name", "expand_all"); + }); +}); diff --git a/web/src/components/core/index.js b/web/src/components/core/index.js index 7e0a4837ff..c881137abb 100644 --- a/web/src/components/core/index.js +++ b/web/src/components/core/index.js @@ -62,3 +62,5 @@ export { default as Reminder } from "./Reminder"; export { default as Tag } from "./Tag"; export { default as TreeTable } from "./TreeTable"; export { default as ControlledPanels } from "./ControlledPanels"; +export { default as Field } from "./Field"; +export { ExpandableField, SettingsField, SwitchField } from "./Field"; diff --git a/web/src/components/layout/Icon.jsx b/web/src/components/layout/Icon.jsx index f32e8cef90..09d2a36e92 100644 --- a/web/src/components/layout/Icon.jsx +++ b/web/src/components/layout/Icon.jsx @@ -28,6 +28,7 @@ import Apps from "@icons/apps.svg?component"; import Badge from "@icons/badge.svg?component"; import CheckCircle from "@icons/check_circle.svg?component"; import ChevronRight from "@icons/chevron_right.svg?component"; +import CollapseAll from "@icons/collapse_all.svg?component"; import Delete from "@icons/delete.svg?component"; import Description from "@icons/description.svg?component"; import Download from "@icons/download.svg?component"; @@ -35,6 +36,7 @@ import Downloading from "@icons/downloading.svg?component"; import Edit from "@icons/edit.svg?component"; import EditSquare from "@icons/edit_square.svg?component"; import Error from "@icons/error.svg?component"; +import ExpandAll from "@icons/expand_all.svg?component"; import ExpandMore from "@icons/expand_more.svg?component"; import Folder from "@icons/folder.svg?component"; import FolderOff from "@icons/folder_off.svg?component"; @@ -64,6 +66,8 @@ import Storage from "@icons/storage.svg?component"; import Sync from "@icons/sync.svg?component"; import TaskAlt from "@icons/task_alt.svg?component"; import Terminal from "@icons/terminal.svg?component"; +import ToggleOff from "@icons/toggle_off.svg?component"; +import ToggleOn from "@icons/toggle_on.svg?component"; import Translate from "@icons/translate.svg?component"; import Tune from "@icons/tune.svg?component"; import Warning from "@icons/warning.svg?component"; @@ -90,6 +94,7 @@ const icons = { badge: Badge, check_circle: CheckCircle, chevron_right: ChevronRight, + collapse_all: CollapseAll, delete: Delete, description: Description, download: Download, @@ -97,6 +102,7 @@ const icons = { edit: Edit, edit_square: EditSquare, error: Error, + expand_all: ExpandAll, expand_more: ExpandMore, folder: Folder, folder_off: FolderOff, @@ -127,6 +133,8 @@ const icons = { sync: Sync, task_alt: TaskAlt, terminal: Terminal, + toggle_off: ToggleOff, + toggle_on: ToggleOn, translate: Translate, tune: Tune, visibility: Visibility,