digitalfabrik/integreat-cms

View on GitHub
integreat_cms/static/src/js/pois/opening-hours/component/opening-hours-input-fields.tsx

Summary

Maintainability
A
0 mins
Test Coverage
/*
 * This component renders the input fields for opening hours of locations
 */
import { Dispatch, StateUpdater, useEffect, useState } from "preact/hooks";
import cn from "classnames";
import { XCircle, CalendarClock } from "lucide-preact";

import { deepCopy } from "../../../utils/deep-copy";
import { OpeningHours } from "../index";

type Props = {
    openingHoursState: [OpeningHours[], Dispatch<StateUpdater<OpeningHours[]>>];
    selectedDaysState: [number[], Dispatch<StateUpdater<number[]>>];
    translations: any;
    days: any;
};

const OpeningHoursInputFields = ({ openingHoursState, selectedDaysState, translations, days }: Props) => {
    // This state contains the current opening hours
    const [openingHours, setOpeningHours] = openingHoursState;
    // This state contains the days which are currently selected for being edited
    const [selectedDays, setSelectedDays] = selectedDaysState;
    // This state contains a buffer for all changes which are not yet saved
    const [openingHoursBuffer, setOpeningHoursBuffer] = useState<OpeningHours>(null);

    // Select or de-select a given day
    const toggleDay = (day: number) => {
        console.debug("Toggling day:", day);
        // Check whether day was already selected
        const index = selectedDays.indexOf(day);
        const newSelectedDay = selectedDays.slice();
        if (index === -1) {
            // If not, add to the array
            newSelectedDay.push(day);
            setSelectedDays(newSelectedDay);
        } else if (selectedDays.length > 1) {
            // Else, remove it (if there is at least one day remaining)
            newSelectedDay.splice(index, 1);
            setSelectedDays(newSelectedDay);
        }
    };

    // Reset to one empty time slot
    const resetTimeSlots = (element: OpeningHours) => {
        /* eslint-disable-next-line no-param-reassign */
        element.timeSlots = [{ start: "", end: "" }];
    };

    // Add a new time slot input fields
    const addTimeSlot = () => {
        console.debug("Adding time slot");
        // Create new object instead of reference to existing one
        const newOpeningHoursBuffer = deepCopy(openingHoursBuffer);
        newOpeningHoursBuffer.timeSlots.push({
            start: "",
            end: "",
        });
        setOpeningHoursBuffer(newOpeningHoursBuffer);
    };

    // Remove the given time slot
    const removeTimeSlot = (event: Event, index: number) => {
        event.preventDefault();
        console.debug("Removing time slot:", index);
        // Create new object instead of reference to existing one
        const newOpeningHoursBuffer = deepCopy(openingHoursBuffer);
        // If only one slot is left, clear its values instead of removing
        if (openingHoursBuffer.timeSlots.length === 1) {
            resetTimeSlots(newOpeningHoursBuffer);
        } else {
            newOpeningHoursBuffer.timeSlots.splice(index, 1);
        }
        setOpeningHoursBuffer(newOpeningHoursBuffer);
    };

    // When the closed checkbox is changed, write the changes to the buffer
    const updateClosed = ({ target }: Event) => {
        console.debug("Changed closed checkbox:", target);
        // Create new object instead of reference to existing one
        const newOpeningHoursBuffer = deepCopy(openingHoursBuffer);
        newOpeningHoursBuffer.closed = (target as HTMLInputElement).checked;
        // If closed was checked, disable all day
        if (newOpeningHoursBuffer.closed) {
            newOpeningHoursBuffer.allDay = false;
            resetTimeSlots(newOpeningHoursBuffer);
        }
        setOpeningHoursBuffer(newOpeningHoursBuffer);
    };

    // When the all day checkbox is changed, write the changes to the buffer
    const updateOpenAllDay = ({ target }: Event) => {
        console.debug("Changed all day checkbox:", target);
        // Create new object instead of reference to existing one
        const newOpeningHoursBuffer = deepCopy(openingHoursBuffer);
        newOpeningHoursBuffer.allDay = (target as HTMLInputElement).checked;
        // If all day was checked, disable closed
        if (newOpeningHoursBuffer.allDay) {
            newOpeningHoursBuffer.closed = false;
            resetTimeSlots(newOpeningHoursBuffer);
        }
        setOpeningHoursBuffer(newOpeningHoursBuffer);
    };

    // When the appointment only checkbox is changed, write the changes to the buffer
    const updateAppointmentOnly = ({ target }: Event) => {
        console.debug("Changed appointment only checkbox:", target);
        // Create new object instead of reference to existing one
        const newOpeningHoursBuffer = deepCopy(openingHoursBuffer);
        newOpeningHoursBuffer.appointmentOnly = (target as HTMLInputElement).checked;
        setOpeningHoursBuffer(newOpeningHoursBuffer);
    };

    // When the opening time is updated, write the changes to the buffer
    const updateOpeningTime = ({ target }: Event) => {
        const timeInput = target as HTMLInputElement;
        console.debug("Changed opening time to:", timeInput.value);

        // Remove red border in case there was an error before
        document.getElementById(`time_slot_error_${timeInput.dataset.index}`).classList.add("hidden");
        timeInput.classList.remove("border-red-500");

        // Create new object instead of reference to existing one
        const newOpeningHoursBuffer = deepCopy(openingHoursBuffer);
        // If a valid value was entered, assume that both all day and closed are false
        if (timeInput.value !== "") {
            newOpeningHoursBuffer.allDay = false;
            newOpeningHoursBuffer.closed = false;
        }
        const index = parseInt(timeInput.dataset.index, 10);
        newOpeningHoursBuffer.timeSlots[index] = { ...newOpeningHoursBuffer.timeSlots[index], start: timeInput.value };
        setOpeningHoursBuffer(newOpeningHoursBuffer);
    };

    // When the closing time is updated, write the changes to the buffer
    const updateClosingTime = ({ target }: Event) => {
        const timeInput = target as HTMLInputElement;
        console.debug("Changed closing time to:", timeInput.value);

        // Remove red border in case there was an error before
        document.getElementById(`time_slot_error_${timeInput.dataset.index}`).classList.add("hidden");
        timeInput.classList.remove("border-red-500");

        // Create new object instead of reference to existing one
        const newOpeningHoursBuffer = deepCopy(openingHoursBuffer);
        // If a valid value was entered, assume that both all day and closed are false
        if (timeInput.value !== "") {
            newOpeningHoursBuffer.allDay = false;
            newOpeningHoursBuffer.closed = false;
        }
        const index = parseInt(timeInput.dataset.index, 10);
        newOpeningHoursBuffer.timeSlots[index] = { ...newOpeningHoursBuffer.timeSlots[index], end: timeInput.value };
        setOpeningHoursBuffer(newOpeningHoursBuffer);
    };

    // Close the input field widget by selecting no days
    const closeInputFields = (event?: Event) => {
        event?.preventDefault();
        console.debug("Closing input widget", openingHours);
        setSelectedDays([]);
        setOpeningHoursBuffer(null);
    };

    const save = (event: Event) => {
        event.preventDefault();
        console.debug("Save current buffer");
        // Validate time inputs
        let errorsOccurred = false;
        let lastTimeSlotEmpty = false;
        openingHoursBuffer.timeSlots.forEach((timeSlot, index) => {
            const timeSlotError = document.getElementById(`time_slot_error_${index}`);
            const openingTimeInput = document.getElementById(`opening_time_${index}`);
            const closingTimeInput = document.getElementById(`closing_time_${index}`);
            if (!timeSlot.start && !timeSlot.end) {
                // Only allow the last time slot to be empty when it's either open all day or closed or there are other slots
                if (index !== openingHoursBuffer.timeSlots.length - 1) {
                    // Require all slots except the last one
                    console.error("Error occurred: Only the last time slot can be empty");
                    openingTimeInput.classList.add("border-red-500");
                    closingTimeInput.classList.add("border-red-500");
                    timeSlotError.textContent = translations.errorOnlyLastSlotEmpty;
                } else if (index === 0 && !openingHoursBuffer.closed && !openingHoursBuffer.allDay) {
                    // Require the last slot when the location is not closed and not open all day and there are no other slots
                    console.error("Either closed, or all day, or one time slot is required.");
                    openingTimeInput.classList.add("border-red-500");
                    closingTimeInput.classList.add("border-red-500");
                    timeSlotError.textContent = translations.errorLastSlotRequired;
                } else {
                    // If the last slot is empty (but allowed to), save it for later so it can be removed
                    lastTimeSlotEmpty = true;
                    return;
                }
            } else if (!timeSlot.start) {
                console.error("Error occurred: opening time is missing");
                openingTimeInput.classList.add("border-red-500");
                timeSlotError.textContent = translations.errorOpeningTimeMissing;
            } else if (!timeSlot.end) {
                console.error("Error occurred: closing time is missing:", timeSlot.start);
                closingTimeInput.classList.add("border-red-500");
                timeSlotError.textContent = translations.errorClosingTimeMissing;
            } else if (timeSlot.start > timeSlot.end) {
                console.error("Error occurred: closing time earlier than opening time:", timeSlot.start);
                closingTimeInput.classList.add("border-red-500");
                timeSlotError.textContent = translations.errorClosingTimeEarlier;
            } else if (timeSlot.start === timeSlot.end) {
                console.error("Error occurred: closing time identical with opening time:", timeSlot.start);
                closingTimeInput.classList.add("border-red-500");
                timeSlotError.textContent = translations.errorClosingTimeIdentical;
            } else if (index > 0 && timeSlot.start < openingHoursBuffer.timeSlots[index - 1].end) {
                console.error(
                    "Error occurred: Opening time is earlier than the closing time of the previous time slot"
                );
                openingTimeInput.classList.add("border-red-500");
                timeSlotError.textContent = translations.errorOpeningTimeEarlier;
            } else if (index > 0 && timeSlot.start === openingHoursBuffer.timeSlots[index - 1].end) {
                console.error(
                    "Error occurred: Opening time is identical with the closing time of the previous time slot"
                );
                openingTimeInput.classList.add("border-red-500");
                timeSlotError.textContent = translations.errorOpeningTimeIdentical;
            } else {
                // Everything looks good - don't show the error
                return;
            }
            // Show error
            timeSlotError.classList.remove("hidden");
            errorsOccurred = true;
        });
        // If error occurred, do not proceed with saving
        if (errorsOccurred) {
            return;
        }
        // If the last time slot is empty, remove it
        if (lastTimeSlotEmpty) {
            openingHoursBuffer.timeSlots.pop();
        }
        // Create shallow clone of opening hours
        const newOpeningHours = openingHours.slice();
        // For each selected day, set the opening hours to the current buffer
        selectedDays.forEach((day) => {
            // Create new object instead of reference to existing one
            newOpeningHours[day] = deepCopy(openingHoursBuffer);
        });
        // Save opening hours
        setOpeningHours(newOpeningHours);
        // Close input field
        closeInputFields();
    };

    // Initialize the buffer with the opening hours of the first day
    useEffect(() => {
        console.debug("Selected days updated:", selectedDays);
        if (openingHoursBuffer === null && selectedDays.length > 0) {
            // Create new object instead of reference to existing one
            const newOpeningHoursBuffer = deepCopy(openingHours[selectedDays[0]]);
            if (newOpeningHoursBuffer.timeSlots.length === 0) {
                resetTimeSlots(newOpeningHoursBuffer);
            }
            setOpeningHoursBuffer(newOpeningHoursBuffer);
            console.debug("Initialized opening hour buffer:", newOpeningHoursBuffer);
        }
    }, [selectedDays, openingHours, openingHoursBuffer]);

    return (
        <div>
            {openingHoursBuffer !== null && (
                <div>
                    <div
                        class="fixed inset-0 opacity-75 bg-gray-800 z-[100]"
                        onClick={closeInputFields}
                        onKeyDown={closeInputFields}
                    />
                    <div class="flex flex-col justify-center w-160 fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[101]">
                        <div class="w-full rounded shadow-2xl bg-white">
                            <div class="flex justify-between font-bold rounded p-4 bg-water-500">
                                <div>
                                    <CalendarClock class="inline-block" />
                                    <span class="uppercase pl-2">{translations.openingHoursLabel}</span>
                                </div>
                                <button
                                    className="rounded-full hover:bg-blue-500 hover:text-white"
                                    onClick={closeInputFields}
                                    type="button"
                                    aria-label="Close">
                                    <XCircle className="inline-block" />
                                </button>
                            </div>
                            <div class="p-4 max-h-[calc(90vh-3.5rem)] overflow-y-auto">
                                <div class="flex flex-wrap justify-between">
                                    <p class="pb-4 w-full">{translations.selectText}</p>
                                    {days.all.map((day: number) => (
                                        <div
                                            key={day}
                                            className={cn(
                                                "rounded-full h-20 w-20 flex flex-wrap place-content-center hover:bg-blue-300 cursor-pointer",
                                                {
                                                    "bg-blue-400": selectedDays.indexOf(day) !== -1,
                                                },
                                                {
                                                    "bg-blue-100": selectedDays.indexOf(day) === -1,
                                                }
                                            )}
                                            onClick={() => toggleDay(day)}
                                            onKeyDown={() => toggleDay(day)}>
                                            <p>{translations.weekdays[day].slice(0, 2)}</p>
                                        </div>
                                    ))}
                                </div>
                                <div>
                                    <input
                                        type="checkbox"
                                        name="all_day"
                                        id="all_day"
                                        onChange={updateOpenAllDay}
                                        checked={openingHoursBuffer.allDay}
                                    />
                                    <label for="all_day" class="mt-4 mb-0">
                                        {translations.allDayLabel}
                                    </label>
                                </div>
                                <div>
                                    <input
                                        type="checkbox"
                                        name="closed"
                                        id="closed"
                                        onChange={updateClosed}
                                        checked={openingHoursBuffer.closed}
                                    />
                                    <label for="closed">{translations.closedLabel}</label>
                                </div>
                                <div>
                                    <input
                                        type="checkbox"
                                        name="appointment_only"
                                        id="appointment_only"
                                        onChange={updateAppointmentOnly}
                                        checked={openingHoursBuffer.appointmentOnly}
                                    />
                                    <label for="appointment_only" class="mt-0 mb-4">
                                        {translations.appointmentOnlyLabel}
                                    </label>
                                </div>
                                {openingHoursBuffer.timeSlots.map((timeSlot, index) => (
                                    <div
                                        // Force re-rendering of fields when a time slot is added or removed
                                        /* eslint-disable-next-line react/no-array-index-key */
                                        key={index + openingHoursBuffer.timeSlots.length}
                                        class="flex flex-wrap gap-4 pb-2">
                                        <fieldset class="border border-solid border-gray-500 p-2 grow">
                                            <legend class="text-sm">
                                                <label for={`opening_time_${index}`} class="my-0">
                                                    {translations.openingTimeLabel}
                                                </label>
                                            </legend>
                                            <input
                                                type="time"
                                                name={`opening_time_${index}`}
                                                id={`opening_time_${index}`}
                                                onInput={updateOpeningTime}
                                                value={timeSlot.start}
                                                data-index={index}
                                            />
                                        </fieldset>
                                        <fieldset class="border border-solid border-gray-500 p-2 grow">
                                            <legend class="text-sm">
                                                <label for={`closing_time_${index}`} class="my-0">
                                                    {translations.closingTimeLabel}
                                                </label>
                                            </legend>
                                            <input
                                                type="time"
                                                name={`closing_time_${index}`}
                                                id={`closing_time_${index}`}
                                                onInput={updateClosingTime}
                                                value={timeSlot.end}
                                                data-index={index}
                                            />
                                        </fieldset>
                                        <div class="flex items-center">
                                            <button
                                                className="rounded-full hover:text-red-500"
                                                title={translations.removeTimeSlotText}
                                                onClick={(event) => removeTimeSlot(event, index)}
                                                type="submit"
                                                aria-label="Remove">
                                                <XCircle className="inline-block" />
                                            </button>
                                        </div>
                                        <div
                                            id={`time_slot_error_${index}`}
                                            className="w-full bg-red-100 border-l-4 border-red-500 text-red-700 px-4 py-3 hidden"
                                            role="alert"
                                        />
                                    </div>
                                ))}
                                <button class="block" onClick={addTimeSlot} onKeyDown={addTimeSlot} type="button">
                                    {translations.addMoreText}
                                </button>
                                <div class="flex flex-wrap justify-end gap-4">
                                    <button class="btn btn-outline" onClick={closeInputFields} type="button">
                                        {translations.cancelText}
                                    </button>
                                    <button class="btn" onClick={save} type="button">
                                        {translations.saveText}
                                    </button>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            )}
        </div>
    );
};
export default OpeningHoursInputFields;