Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[web] Simplify code of Section component #839

Merged
merged 4 commits into from
Nov 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 9 additions & 55 deletions web/src/components/core/Section.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,54 +26,6 @@ import { Link } from "react-router-dom";
import { Icon } from '~/components/layout';
import { ValidationErrors } from "~/components/core";

/**
* Internal component for rendering the section icon
*
* @param {object} props
* @param {string} [props.name] - the name of the icon
* @param {number} [props.size=32] - the icon size
*
* @return {React.ReactElement}
*/
const SectionIcon = ({ name, size = 32 }) => {
if (!name) return null;

return <Icon name={name} size={size} aria-hidden />;
};

/**
* Internal component for rendering the section title
*
* @param {object} props
* @param {string} props.id - the id for the header.
* @param {string} props.text - the title for the section.
* @param {string} props.path - the path where the section links to.
*
* @return {JSX.Element}
*/
const SectionTitle = ({ id, text, path }) => {
if (!text?.trim()) return null;

const title = !path?.trim() ? <>{text}</> : <Link to={path}>{text}</Link>;

return <h2 id={id}>{title}</h2>;
};

/**
* Internal component for wrapping and rendering the section content
*
* @param {object} props
* @param {React.ReactElement|React.ReactElement[]} props.children - the content to be wrapped
* @return {JSX.Element}
*/
const SectionContent = ({ children }) => {
return (
<div className="stack content">
{children}
</div>
);
};

/**
* Renders children into an HTML section
* @component
Expand Down Expand Up @@ -122,13 +74,15 @@ export default function Section({
console.error("The Section component must have either, a 'title' or an 'aria-label'");
}

const SectionHeader = () => {
if (!title) return;
const Header = () => {
if (!title?.trim()) return;

const header = !path?.trim() ? <>{title}</> : <Link to={path}>{title}</Link>;

return (
<>
<SectionIcon name={loading ? "loading" : icon} />
<SectionTitle id={headerId} text={title} path={path} />
<Icon name={loading ? "loading" : icon} />
<h2 id={headerId}>{header}</h2>
</>
);
};
Expand All @@ -140,12 +94,12 @@ export default function Section({
aria-label={ariaLabel || undefined}
aria-labelledby={ title && !ariaLabel ? headerId : undefined}
>
<SectionHeader />
<SectionContent>
<Header />
<div className="stack content">
{errors?.length > 0 &&
<ValidationErrors errors={errors} title={`${title} errors`} />}
{children}
</SectionContent>
</div>
</section>
);
}
23 changes: 14 additions & 9 deletions web/src/components/layout/Icon.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,6 @@
*/

import React from 'react';
import { sprintf } from "sprintf-js";

import { _ } from "~/i18n";

// NOTE: "@icons" is an alias to use a shorter path to real @material-symbols
// icons location. Check the tsconfig.json file to see its value.
Expand Down Expand Up @@ -132,23 +129,31 @@ const icons = {
*
* If exists, it renders requested icon with given size.
*
* @note: if either, name prop has a falsy value or requested icon is not found,
* it will outputs a message to the console.error and renders nothing.
*
* @todo: import icons dynamically if the list grows too much. See
* - https://stackoverflow.com/a/61472427
* - https://ryanhutzley.medium.com/dynamic-svg-imports-in-create-react-app-d6d411f6d6c6
*
* @example
* <Icon name="warning" size="16" />
*
* @param {object} props - component props
* @param {string} props.name - desired icon
* @param {string} [props.className=""] - CSS classes
* @param {string|number} [props.size=32] - the icon width and height
* @param {object} [props.otherProps] other props sent to SVG icon
* @param {object} props - Component props
* @param {string} props.name - Name of the desired icon.
* @param {string} [props.className=""] - CSS classes.
* @param {string|number} [props.size=32] - Size used for both, width and height.
* @param {object} [props.otherProps] Other props sent to SVG icon.
*
*/
export default function Icon({ name, className = "", size = 32, ...otherProps }) {
if (!name) {
console.error(`Icon called without name. '${name}' given instead. Rendering nothing.`);
return null;
}

if (!icons[name]) {
console.error(sprintf(_("Icon %s not found!"), name));
console.error(`Icon '${name}' not found!`);
return null;
}

Expand Down
71 changes: 48 additions & 23 deletions web/src/components/layout/Icon.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,7 @@ import React from "react";
import { plainRender } from "~/test-utils";
import { Icon } from "~/components/layout";

describe("when given a known name", () => {
it("renders an aria-hidden SVG element", async () => {
const { container } = plainRender(<Icon name="wifi" />);
const svgElement = container.querySelector('svg');
expect(svgElement).toHaveAttribute("aria-hidden", "true");
});

it("includes the icon name as a data attribute of the SVG", async () => {
const { container } = plainRender(<Icon name="wifi" />);
const svgElement = container.querySelector('svg');
expect(svgElement).toHaveAttribute("data-icon-name", "wifi");
});
});

describe("when given an unknown name", () => {
describe("Icon", () => {
beforeAll(() => {
jest.spyOn(console, "error").mockImplementation();
});
Expand All @@ -46,15 +32,54 @@ describe("when given an unknown name", () => {
console.error.mockRestore();
});

it("outputs to console.error", () => {
plainRender(<Icon name="apsens" />);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining("apsens not found")
);
describe("mounted with a falsy value as name", () => {
it("outputs to console.error", () => {
plainRender(<Icon name="" />);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining("Rendering nothing")
);
});

it("renders nothing", () => {
const { container: contentWhenNotDefined } = plainRender(<Icon />);
expect(contentWhenNotDefined).toBeEmptyDOMElement();

const { container: contentWhenEmpty } = plainRender(<Icon name="" />);
expect(contentWhenEmpty).toBeEmptyDOMElement();

const { container: contentWhenFalse } = plainRender(<Icon name={false} />);
expect(contentWhenFalse).toBeEmptyDOMElement();

const { container: contentWhenNull } = plainRender(<Icon name={null} />);
expect(contentWhenNull).toBeEmptyDOMElement();
});
});

describe("mounted with a known name", () => {
it("renders an aria-hidden SVG element", async () => {
const { container } = plainRender(<Icon name="wifi" />);
const svgElement = container.querySelector('svg');
expect(svgElement).toHaveAttribute("aria-hidden", "true");
});

it("includes the icon name as a data attribute of the SVG", async () => {
const { container } = plainRender(<Icon name="wifi" />);
const svgElement = container.querySelector('svg');
expect(svgElement).toHaveAttribute("data-icon-name", "wifi");
});
});

it("renders nothing", async () => {
const { container } = plainRender(<Icon name="apsens" />);
expect(container).toBeEmptyDOMElement();
describe("mounted with unknown name", () => {
it("outputs to console.error", () => {
plainRender(<Icon name="apsens" />);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining("'apsens' not found")
);
});

it("renders nothing", async () => {
const { container } = plainRender(<Icon name="apsens" />);
expect(container).toBeEmptyDOMElement();
});
});
});
Loading