/* eslint-disable @typescript-eslint/no-explicit-any */ import { useEffect, useRef, useState } from "react"; import FactoryRenderer from "../../../../Renderer"; import { useClickOutside } from "../../../../utils/useClickOutside"; import { View } from "../../data/userView"; import { VIEW_NAME_MAX_LENGTH, validateViewName, } from "./helpers/validateViewName"; /** * Where the configuration of a brand-new view comes from. Creating a view used * to always produce an empty one (`DEFAULT`), so a user who had just tuned the * grid had to create the view, select it, re-apply everything and press Save. */ export enum NewViewSource { /** * Create the view AND save what is applied right now into it, in the single * Create click — the create and the Save-button behaviour combined. */ CURRENT = "CURRENT", /** Save nothing — the view opens on the screen's default configuration. */ DEFAULT = "DEFAULT", /** Duplicate another view's saved configuration. */ COPY = "COPY", } export type NewViewOptions = { source: NewViewSource; /** Only meaningful for `COPY`. */ copyFromViewId?: string; }; interface CreateScreenPopupProps { onViewToggle: () => void; create: (viewName: string, options: NewViewOptions) => Promise; /** * The view being renamed. Its presence is what puts this dialog in **edit * mode**: same window, same field, same name rule — only the title, the * action's label and what the action calls differ. * * Edit mode hides the source section entirely. "Current setup / Default * setup / Copy existing" decides where a BRAND-NEW view's configuration * comes from; a rename touches the name and nothing else, so offering it * would suggest renaming could also overwrite the view's saved state. */ editView?: View | null; /** Required in edit mode. Resolves to the server's message, or `""`. */ rename?: (id: string, viewName: string) => Promise; /** Views offered as copy sources. `Copy existing` is hidden when empty. */ copyableViews?: View[]; /** Preselected copy source — the view the user is currently looking at. */ currentViewId?: string; /** What "Current setup" would save, e.g. ["3 advanced filters", "8 columns"]. */ currentSummary?: string[]; /** Resolves what a copy source would bring across. */ loadViewSummary?: (viewId: string) => Promise; error?: string; } /** * Value carried by the TEXTBOX widget's change relay. Replaces Kendo's * `TextBoxChangeEvent`, which was a type-only import but still made this file * depend on the Kendo inputs package at compile time. */ type WidgetChangeValue = string | number | null | undefined; // Ordered by expected use: the default lands on the option that needs no // further input, so the common path is "type a name, press Enter". const SOURCE_OPTIONS: Array<{ value: NewViewSource; label: string; hint: string; }> = [ { value: NewViewSource.CURRENT, label: "Current setup", hint: "Creates the view and saves everything applied right now — your changes and the screen's defaults", }, { value: NewViewSource.DEFAULT, label: "Default setup", hint: "Creates an empty view that opens on the screen's default configuration", }, { value: NewViewSource.COPY, label: "Copy existing", hint: "Creates the view with another view's saved setup", }, ]; const CreateScreenPopup = (props: CreateScreenPopupProps) => { const isEdit = Boolean(props.editView); const [screenName, setScreenName] = useState(props.editView?.name ?? ""); const [validationError, setValidationError] = useState(""); // Edit mode's own failure — a duplicate name comes back from the server, not // from the validator. Create mode gets the same thing through `props.error`, // which its parent owns because a failed create has to survive the popup // being re-rendered; a failed rename does not, so it stays local. const [renameError, setRenameError] = useState(""); const [isRenaming, setIsRenaming] = useState(false); const [source, setSource] = useState(NewViewSource.CURRENT); const [copyFromView, setCopyFromView] = useState(null); const [copySummary, setCopySummary] = useState(null); const [isSummaryLoading, setIsSummaryLoading] = useState(false); const popupRef = useRef(null); const closeTimerRef = useRef | null>(null); // Guards against an earlier summary request resolving after a later one and // painting the wrong view's contents next to the picked view. const summaryRequestRef = useRef(0); const copyableViews = props.copyableViews ?? []; const sourceOptions = SOURCE_OPTIONS.filter( (option) => option.value !== NewViewSource.COPY || copyableViews.length > 0, ); useClickOutside( popupRef, true, () => { popupRef.current?.classList.add("tmpl-popup-animation-2-reverse"); // The close timer was previously never cleared: a second outside click // scheduled a second `onViewToggle`, which could toggle the popup back open. if (closeTimerRef.current !== null) clearTimeout(closeTimerRef.current); closeTimerRef.current = setTimeout(props.onViewToggle, 200); }, // The copy list renders in a Kendo portal outside popupRef, so picking a // view would otherwise read as an outside click and discard the half-filled // form. Same exemption idiom as AdvancedSearch/QuickFilter. (target) => Boolean(target.closest(".tmpl-prvent-outside-click-close")), ); useEffect( () => () => { if (closeTimerRef.current !== null) clearTimeout(closeTimerRef.current); }, [], ); // Focus the name field on open so the popup is typeable without a click. // Done through the DOM rather than a widget prop so it holds whatever the // TEXTBOX widget renders underneath. useEffect(() => { popupRef.current?.querySelector("input")?.focus(); }, []); // A copy source shows the same kind of summary the current setup does, so // both branches answer "what exactly gets saved?" before the click. useEffect(() => { if (source !== NewViewSource.COPY || !copyFromView || !props.loadViewSummary) { setCopySummary(null); setIsSummaryLoading(false); return; } const requestId = ++summaryRequestRef.current; setIsSummaryLoading(true); props .loadViewSummary(copyFromView.id) .then((summary) => { if (requestId !== summaryRequestRef.current) return; setCopySummary(summary ?? []); setIsSummaryLoading(false); }) .catch(() => { if (requestId !== summaryRequestRef.current) return; setCopySummary([]); setIsSummaryLoading(false); }); }, [source, copyFromView?.id]); const isCreatable = !validateViewName(screenName) && // A copy source is only owed in create mode — edit mode has no source // section at all. (isEdit || source !== NewViewSource.COPY || !!copyFromView) && !isRenaming; const handleRename = async () => { const error = validateViewName(screenName); if (error) { setValidationError(error); return; } const name = screenName.trim(); // Nothing to send. Closing silently is the honest answer to "rename it to // what it is already called"; an error would be inventing a problem. if (name === props.editView?.name) { props.onViewToggle(); return; } setIsRenaming(true); const failure = (await props.rename?.(props.editView!.id, name)) ?? ""; if (failure) { // Stays open with the offending name still in the field, so it can be // edited rather than retyped. setRenameError(failure); setIsRenaming(false); return; } props.onViewToggle(); }; const handleCreate = () => { if (isEdit) { void handleRename(); return; } const error = validateViewName(screenName); if (error || !isCreatable) { setValidationError(error); return; } props.create(screenName.trim(), { source, copyFromViewId: source === NewViewSource.COPY ? copyFromView?.id : undefined, }); }; const onScreenNameChange = ( _callBack: unknown, _screenDataField: unknown, value: WidgetChangeValue, ) => { const nextValue = value?.toString() || ""; setScreenName(nextValue); setValidationError(validateViewName(nextValue)); // Cleared as the user types, not held until they commit: the message they // are reading is about a name they have already changed. setRenameError(""); }; const onSourceChange = (nextSource: NewViewSource) => { setSource(nextSource); // Choosing "Copy existing" preselects the view the user is on — the usual // intent — so duplicating the current view costs one click, not two. if (nextSource === NewViewSource.COPY && !copyFromView) { setCopyFromView( copyableViews.find((view) => view.id === props.currentViewId) ?? copyableViews[0] ?? null, ); } }; const onCopyFromChange = ( _callBack: unknown, _screenDataField: unknown, value: View | null, ) => { setCopyFromView(value?.id ? value : null); }; // Enter anywhere in the name field submits. Restricted to inputs so Enter on // a focused option button keeps its native "activate this option" meaning. const onKeyDown = (event: React.KeyboardEvent) => { if (event.key !== "Enter") return; if ((event.target as HTMLElement)?.tagName !== "INPUT") return; event.preventDefault(); if (isCreatable) handleCreate(); }; const activeHint = SOURCE_OPTIONS.find( (option) => option.value === source, )?.hint; /** * One preview block for both saving branches. `null` summary = nothing to * preview (default setup, or a copy source with nothing saved yet). */ const renderSummary = () => { if (source === NewViewSource.DEFAULT) { return

{activeHint}

; } if (source === NewViewSource.COPY && isSummaryLoading) { return

Loading view setup…

; } const summary = source === NewViewSource.CURRENT ? props.currentSummary ?? [] : copySummary ?? []; if (summary.length === 0) { return (

{source === NewViewSource.CURRENT ? "Only the screen's default setup is applied right now — that is what gets saved" : "This view has nothing saved yet — the new view will start on the default setup"}

); } return (

Will be saved

{summary.map((item, index) => ( {item} ))}
); }; return (
{/* Both modes of this dialog — Create New View and Edit View — title through this one

, carrying the theme's `qo-h3` and no type of its own. The
that wrapped it went with the subtitle it was grouping the title with; the header has one flex child a side. */}

{isEdit ? "Edit View" : "Create New View"}

{/* The theme's form label, with its required marker drawn by the theme — not a hand-coloured `*` in an inline style. */} View Name {validationError && ( {validationError} )} {props.error && {props.error}} {renameError && ( {renameError} )}
{/* Create only. "Start With" decides where a brand-new view's configuration comes from; a rename touches the name and nothing else, and offering the choice here would suggest renaming could also overwrite the view's saved state. In edit mode the dialog is the name field and nothing more. */} {!isEdit && (
Start With
{sourceOptions.map((option) => ( ))}
)} {!isEdit && source === NewViewSource.COPY && (
Copy From
)} {/* The preview answers "what exactly gets saved?" — a question a rename does not raise. */} {!isEdit &&
{renderSummary()}
}

); }; export default CreateScreenPopup;