🏡 index : quine-global/quine-anaconda-webui.git

/*
 * Copyright (C) 2024 Red Hat, Inc.
 * SPDX-License-Identifier: LGPL-2.1-or-later
 */

import cockpit from "cockpit";

import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
import { Content, ContentVariants } from "@patternfly/react-core/dist/esm/components/Content/index.js";
import { Divider } from "@patternfly/react-core/dist/esm/components/Divider/index.js";
import { Label } from "@patternfly/react-core/dist/esm/components/Label/index.js";
import { List, ListItem } from "@patternfly/react-core/dist/esm/components/List/index.js";
import { Modal, ModalBody, ModalFooter, ModalHeader, ModalVariant } from "@patternfly/react-core/dist/esm/components/Modal/index.js";
import { Tooltip } from "@patternfly/react-core/dist/esm/components/Tooltip/index.js";
import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
import { Grid, GridItem } from "@patternfly/react-core/dist/esm/layouts/Grid/index.js";
import ArrowDownIcon from "@patternfly/react-icons/dist/esm/icons/arrow-down-icon";
import ArrowUpIcon from "@patternfly/react-icons/dist/esm/icons/arrow-up-icon";
import StarIcon from "@patternfly/react-icons/dist/esm/icons/star-icon";
import TrashIcon from "@patternfly/react-icons/dist/esm/icons/trash-icon";

import {
    setCompositorLayouts,
    setXKeyboardDefaults,
    setXLayouts,
} from "../../apis/localization.js";

import {
    getKeyboardConfigurationAction,
} from "../../actions/localization-actions.js";

import { getLocaleById } from "../../helpers/localization.js";

import { LanguageContext, PageContext } from "../../contexts/Common.jsx";

import { MenuSearch } from "../common/MenuSearch.jsx";

import "./Keyboard.scss";

const _ = cockpit.gettext;
const SCREEN_ID = "anaconda-screen-language";

const SelectedKeyboards = ({ xlayouts }) => (
    <Content component="p" id={SCREEN_ID + "-selected-keyboards"}>
        {!xlayouts?.length && _("No keyboard layouts selected")}
        {xlayouts?.length === 1 && xlayouts[0]}
        {xlayouts?.length > 1 && (
            <span>
                <strong>{xlayouts[0]}</strong>
                {xlayouts.slice(1).map((layout, index) => (
                    <span key={`${layout}-${index}`}>, {layout}</span>
                ))}
            </span>
        )}
    </Content>
);

const buildMenuItem = (keyboard) => {
    const {
        description,
        "is-common": isCommon,
        "layout-id": layoutId,
    } = keyboard;

    const id = (
        SCREEN_ID +
        "-keyboard-" +
        (isCommon.v ? "option-common-" : "option-alpha-") +
        layoutId?.v.replace(/[\s()]/g, "_")
    );

    return {
        id,
        item: keyboard,
        itemId: layoutId?.v,
        itemText: description.v,
        itemType: "menu-item",
        key: layoutId?.v,
        onSearch: search => {
            const searchLower = search.toLowerCase();
            return (
                description.v.toLowerCase().includes(searchLower) ||
                layoutId?.v.toLowerCase().includes(searchLower)
            );
        },
    };
};

const buildMenuGroup = (keyboards, showCommon) => ({
    id: SCREEN_ID + "-keyboard-group-" + (showCommon ? "common" : "other") + "-keyboards",
    itemChildren: keyboards
            .filter(keyboard => keyboard["is-common"].v === showCommon)
            .map(keyboard => buildMenuItem(keyboard)),
    itemLabel: showCommon ? _("Suggested keyboards") : _("Other keyboards"),
    itemLabelHeadingLevel: "h3",
    itemType: "menu-group",
});

const moveIndex = (arr, from, to) => {
    if (to < 0 || to >= arr.length) return arr;
    const newArray = arr.slice();
    const [movedItem] = newArray.splice(from, 1);
    newArray.splice(to, 0, movedItem);
    return newArray;
};
const uniqPush = (arr, id) => (arr.includes(id) ? arr : [...arr, id]);

const LayoutActionButtons = ({ index, modalId, setSelected, totalItems }) => {
    const cssClasses = {
        actions: "change-system-keyboard-layout-modal-actions"
    };

    const makeCurrent = (currentIndex) => setSelected(selected => moveIndex(selected, currentIndex, 0));
    const moveUp = (currentIndex) => setSelected(selected => moveIndex(selected, currentIndex, currentIndex - 1));
    const moveDown = (currentIndex) => setSelected(selected => moveIndex(selected, currentIndex, currentIndex + 1));
    const removeAt = (currentIndex) => setSelected(selected => selected.filter((_, idx) => idx !== currentIndex));

    return (
        <div className={cssClasses.actions}>
            <Tooltip content={_("Make current")}>
                <Button
                  id={`${modalId}-make-current-${index}`}
                  variant="plain"
                  aria-label={_("Make current")}
                  isDisabled={index === 0}
                  onClick={() => makeCurrent(index)}
                >
                    <StarIcon />
                </Button>
            </Tooltip>
            <Tooltip content={_("Move up")}>
                <Button
                  id={`${modalId}-move-up-${index}`}
                  variant="plain"
                  aria-label={_("Move up")}
                  isDisabled={index === 0}
                  onClick={() => moveUp(index)}
                >
                    <ArrowUpIcon />
                </Button>
            </Tooltip>
            <Tooltip content={_("Move down")}>
                <Button
                  id={`${modalId}-move-down-${index}`}
                  variant="plain"
                  aria-label={_("Move down")}
                  isDisabled={index === totalItems - 1}
                  onClick={() => moveDown(index)}
                >
                    <ArrowDownIcon />
                </Button>
            </Tooltip>
            <Tooltip content={_("Remove")}>
                <Button
                  id={`${modalId}-remove-${index}`}
                  variant="plain"
                  aria-label={_("Remove")}
                  onClick={() => removeAt(index)}
                >
                    <TrashIcon />
                </Button>
            </Tooltip>
        </div>
    );
};

const KeyboardDialog = ({ currentLayouts = [], onClose, onSaved }) => {
    const modalId = SCREEN_ID + "-change-system-keyboard-layout-modal";
    const cssClasses = {
        listRow: "change-system-keyboard-layout-modal-list-row"
    };
    const [alert, setAlert] = useState();
    const [selected, setSelected] = useState(currentLayouts);
    const { keyboardLayouts } = useContext(LanguageContext);

    const currentLocale = getLocaleById(keyboardLayouts, selected?.[0]);
    const currentSupportsAscii = currentLocale?.["supports-ascii"]?.v === true;

    const options = useMemo(
        () => [buildMenuGroup(keyboardLayouts, true), buildMenuGroup(keyboardLayouts, false)],
        [keyboardLayouts]
    );

    useEffect(() => {
        setAlert();
    }, [selected]);

    const addLayout = (_e, item) => {
        const id = typeof item === "string" ? item : item?.itemId || item?.key;
        if (id) {
            setSelected(selected => uniqPush(selected, id));
        }
    };

    const saveAll = async () => {
        try {
            onSaved?.(selected);
            onClose();
        } catch (e) {
            setAlert(e?.message || _("Failed to save layouts"));
        }
    };

    return (
        <Modal
          id={modalId}
          isOpen
          position="top"
          variant={ModalVariant.large}
        >
            <ModalHeader title={_("Change system keyboard layout")} />
            <ModalBody>
                <Grid hasGutter>
                    <GridItem span={7}>
                        <Content component={ContentVariants.h4}>
                            {_("Find layout")}
                        </Content>
                        <div id={modalId + "-left-panel"}>
                            <MenuSearch
                              ariaLabelSearch={_("Search keyboard layout")}
                              handleOnSelect={addLayout}
                              menuType="keyboard"
                              options={options}
                              screenId={SCREEN_ID}
                            />
                        </div>
                    </GridItem>

                    <GridItem span={5}>
                        <Content component={ContentVariants.h4}>
                            {_("Selected (top = current)")}
                        </Content>
                        <Divider />
                        <div id={modalId + "-right-panel"}>
                            {selected.length === 0
                                ? (
                                    <Content component={ContentVariants.p}>
                                        {_("No layouts selected yet")}
                                    </Content>
                                )
                                : (
                                    <List id={modalId + "-list"} isPlain>
                                        {selected.map((id, idx) => (
                                            <ListItem key={`${id}-${idx}`}>
                                                <div className={cssClasses.listRow}>
                                                    <Label color={idx === 0 ? "green" : "grey"}>{id}</Label>
                                                    <LayoutActionButtons
                                                      index={idx}
                                                      totalItems={selected.length}
                                                      setSelected={setSelected}
                                                      modalId={modalId}
                                                    />
                                                </div>
                                            </ListItem>
                                        ))}
                                    </List>
                                )}

                            {selected.length > 0 && !currentSupportsAscii && (
                                <Alert isInline variant="warning" title={_("The selected default keyboard layout does not support ASCII characters. Please choose an ASCII-capable layout as the default.")} />
                            )}
                            {alert && <Alert isInline isPlain title={alert} variant="info" />}
                        </div>
                    </GridItem>
                </Grid>
            </ModalBody>
            <ModalFooter>
                <Button
                  id={modalId + "-save-button"}
                  key="save"
                  variant="primary"
                  isDisabled={selected.length < 1}
                  onClick={saveAll}
                >
                    {_("Save")}
                </Button>
                <Button key="cancel" variant="link" onClick={onClose}>
                    {_("Cancel")}
                </Button>
            </ModalFooter>
        </Modal>
    );
};

export const KeyboardGnome = ({ dispatch, onError, onSuccess }) => {
    const { plannedXlayouts } = useContext(LanguageContext);

    useEffect(() => {
        const onFocus = () => {
            dispatch(getKeyboardConfigurationAction({ onError, onSuccess }));
        };

        window.addEventListener("focus", onFocus);
        return () => window.removeEventListener("focus", onFocus);
    }, [dispatch, onError, onSuccess]);

    return (
        <>
            <Flex alignItems="center" flexWrap={{ default: "nowrap" }}>
                <SelectedKeyboards xlayouts={plannedXlayouts} />
                <Button
                  variant="link"
                  component="a"
                  href="anaconda-gnome-control-center://keyboard"
                >
                    {_("Change system keyboard layout")}
                </Button>
            </Flex>
        </>
    );
};

const KeyboardNonGnome = () => {
    const modalId = SCREEN_ID + "-change-system-keyboard-layout-modal";
    const [keyboardAlert, setKeyboardAlert] = useState();
    const [open, setOpen] = useState(false);
    const { language, plannedXlayouts, xlayouts } = useContext(LanguageContext);
    const langRef = useRef(language);

    useEffect(() => {
        // On initial load, if no X layouts, set sensible defaults
        if (xlayouts?.length > 0) {
            return;
        }
        setXKeyboardDefaults();
    }, [xlayouts]);

    useEffect(() => {
        // When language changes, choose sensible defaults for X keyboard layouts
        if (langRef.current === language) {
            return;
        }
        langRef.current = language;
        setXKeyboardDefaults();
    }, [language]);

    const handleSaved = async (selectedLayouts) => {
        try {
            await setCompositorLayouts({ layouts: selectedLayouts });
            await setXLayouts({ layouts: selectedLayouts });
        } catch (ex) {
            setKeyboardAlert(ex?.message);
        }
    };

    const selectedKeyboards = <SelectedKeyboards xlayouts={plannedXlayouts} />;

    return (
        <>
            <Flex alignItems="center" flexWrap={{ default: "nowrap" }}>
                {selectedKeyboards}
                <Button
                  id={modalId + "-open-button"}
                  variant="link"
                  onClick={() => setOpen(true)}
                >
                    {_("Change system keyboard layout")}
                </Button>
            </Flex>
            {keyboardAlert &&
            <Alert
              isInline
              isPlain
              title={keyboardAlert}
              variant="danger"
            />}
            {open &&
            <KeyboardDialog
              onClose={() => setOpen(false)}
              onSaved={handleSaved}
              currentLayouts={plannedXlayouts}
            />}
        </>
    );
};

export const Keyboard = ({ dispatch, isGnome, setIsKeyboardValid }) => {
    const { plannedVconsole, plannedXlayouts } = useContext(LanguageContext);
    const { setStepNotification } = useContext(PageContext) ?? {};
    const [keyboardConfigError, setKeyboardConfigError] = useState();

    useEffect(() => {
        if (!keyboardConfigError) {
            setStepNotification?.(null);
            return;
        }
        setStepNotification?.({
            message: keyboardConfigError,
            step: "anaconda-screen-language",
        });
    }, [keyboardConfigError, setStepNotification]);

    // Fetch keyboard configuration when component mounts
    useEffect(() => {
        dispatch(getKeyboardConfigurationAction({
            onError: setKeyboardConfigError,
            onSuccess: () => {
                setKeyboardConfigError();
            }
        }));
    }, [dispatch]);

    useEffect(() => {
        setIsKeyboardValid(
            (plannedVconsole ?? "") !== "" &&
            (plannedXlayouts?.length > 0) &&
            !keyboardConfigError
        );
    }, [
        isGnome,
        keyboardConfigError,
        plannedVconsole,
        plannedXlayouts,
        setIsKeyboardValid
    ]);

    if (!plannedXlayouts?.length && !keyboardConfigError) {
        return null;
    }

    const keyboardAlert = (
        <Alert
          isInline
          isPlain
          title={_("No valid VConsole keymap can be obtained for the selected keyboard layout(s). Please make a different keyboard layout selection.")}
          variant="danger"
        />
    );
    const keyboard = (
        isGnome
            ? (
                <KeyboardGnome
                  dispatch={dispatch}
                  onError={setKeyboardConfigError}
                  onSuccess={() => {
                      setKeyboardConfigError();
                  }}
                />
            )
            : <KeyboardNonGnome />
    );

    return (
        <>
            {keyboard}
            {plannedVconsole === "" && keyboardAlert}
        </>
    );
};