/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-explicit-any */ import UserViewView from "./UserViewView"; import { useEffect, useState, useMemo } from "react"; import { SYSTEM_DEFAULT } from "../../../../constant"; import _ from "lodash"; import CreateScreenPopup, { NewViewOptions, NewViewSource, } from "./CreateScreenPopup"; import { View } from "../../data/userView"; import { createScreenAction, deleteScreenAction, loadScreenByIdAction, loadScreensAction, markDefaultScreenAction, renameScreenAction, updateScreenAction, } from "./actions"; import { saveSystemDefaultInSession } from "./helpers/saveSystemDefaultInSession"; import getSavedSystemDefault from "./helpers/getSavedSystemDefault"; import { getInitialData } from "../../data"; import { hasUnsavedChanges } from "./helpers/hasUnsavedChanges"; import { FilterTarget } from "../../data/advancedSearch"; import { FilterExpression, FilterTarget as FilterTargetQuick, } from "../../data/quickFilter"; import { DataWidgetType } from "../Setting/Setting"; import { ChooserColumn } from "../../data/setting"; import { getUniqueId } from "../../Helper/getUniqueId"; import { setupServiceInterceptors } from "./services"; import { replaceCustomViewData } from "./helpers/replaceCustomViewData"; import { filterCustomViewData } from "./helpers/filterCustomViewData"; import { getSystemDefaultSessionKey } from "./helpers/getSystemDefaultSessionKey"; import { resolveSelectedView, saveSelectedViewInSession, } from "./helpers/selectedViewSession"; import { restoreQuickFilterRowValues } from "./helpers/restoreQuickFilterRowValues"; import { summarizeCustomViewData, summarizeSavedViewData, } from "./helpers/summarizeCustomViewData"; export const _defaultView = { id: SYSTEM_DEFAULT, isDefault: false, name: "System Default", isSelected: false, }; export type UserViewProps = { visible: boolean; disabled: boolean; enableDefaultSaveInSession: boolean; advancedFilterableFields: FilterTarget[]; quickFilterRows: FilterTargetQuick[]; activeDataWidgetType: DataWidgetType; columns: ChooserColumn[]; config: { uiElementGroupId: string }; uiElementGroupData: Record; onModelUpdate: ( callBack: ((args: any) => void) | null, fieldName: string, value: any, ) => void; getUniqueViewId?: (callBack: (response: string) => void) => void; eventService?: any; toolBarOptions?: any; }; const UserView = (props: UserViewProps) => { const [togglePopup, setTogglePopup] = useState(false); const [savedViewData, setSavedViewData] = useState | null>(null); const [createViewError, setCreateViewError] = useState(""); // Non-null while the view dialog is open in edit mode. Separate from // `togglePopup`, which is the create path — the two open the same dialog but // from different places and cannot be open at once. const [viewToEdit, setViewToEdit] = useState(null); useEffect(() => { if (!props.eventService) return; // The axios instance is a module singleton, so the interceptors must be // ejected on cleanup — the core creates a new EventService per screenId and // this effect re-fires on every screen change. return setupServiceInterceptors(props.eventService); }, [props.eventService]); useEffect(() => { if (props.uiElementGroupData?.processing === false) { loadUserViews(); } }, [props.uiElementGroupData?.processing]); useEffect(() => { //call custom view service and get saved data related to default custom view if (props.uiElementGroupData?.reset > 0) { // `allViews` is `any`, so tsc cannot see that `.find` returns undefined // when no entry carries isDefault — reachable by deleting the default // view, which previously threw a TypeError on the next Reset. const allViews: View[] = props.uiElementGroupData?.userView?.allViews ?? []; const selectedView: View | undefined = allViews.find((view: View) => view.isDefault) ?? allViews.find((view: View) => view.id === SYSTEM_DEFAULT) ?? allViews[0]; if ( selectedView?.id === SYSTEM_DEFAULT && props.enableDefaultSaveInSession ) { sessionStorage.removeItem( getSystemDefaultSessionKey( props.config.uiElementGroupId, props.getUniqueViewId, ), ); } initScreenData( props.uiElementGroupData?.userView?.allViews ?? [], selectedView, true, ); } }, [props.uiElementGroupData?.reset]); const initScreenData = async ( allScreens: View[], selectedScreen: View, isReset?: boolean, ) => { const initialData = getInitialData(); initialData.advancedSearch.filterRows[0].allPropertieseToFilter = _.cloneDeep(props.advancedFilterableFields); initialData.enableDataLoading = true; initialData.quickFilter.filterRows = props.quickFilterRows.map( (filterRow) => { const filterExpression: FilterExpression = { id: "QUFS__" + getUniqueId(), propertyToFilter: _.cloneDeep(filterRow), allValues: [], value: undefined, }; return filterExpression; }, ); // On reset the configured default wins; otherwise keep whatever widget type // the user is currently on. Either way a saved view's own setting is applied // over this by loadSelectedViewData below. initialData.setting.activeDataWidgetType = isReset ? props.activeDataWidgetType : props.uiElementGroupData?.setting?.activeDataWidgetType ?? props.activeDataWidgetType; initialData.setting.columnChooser.allColumns = _.cloneDeep(props.columns); initialData.setting.columnChooser.appliedColumns = _.cloneDeep( props.columns.filter((c) => c.selected), ); initialData.setting.columnChooser.selectedColumns = _.cloneDeep( props.columns .filter((c) => c.selected) ?.sort((a, b) => a.order - b.order), ); initialData.userView.allViews = allScreens; initialData.userView.selectedView = selectedScreen; let mergedInitialData = { ...props.uiElementGroupData, ...initialData, }; if (isReset) { mergedInitialData.localSearch = props.uiElementGroupData?.defaultLocalSearch; mergedInitialData.sort = mergedInitialData.defaultSort; mergedInitialData.pagination = mergedInitialData.defaultPagination; // Reset must always refetch, even when the toolbar was already at its // defaults. The load effect in GridToolBar keys off the *values* of the // applied queries, sort and pagination, so a reset that changes nothing // produced identical deps and silently skipped the call. `needLoadData` // is the existing force-a-refetch seam (the data widgets flip it the same // way on unmount / widget swap); flipping it here rides along in this one // model update, so the fetch fires exactly once with the reset state. mergedInitialData.needLoadData = !( props.uiElementGroupData?.needLoadData ?? false ); } if (selectedScreen) { mergedInitialData = await loadSelectedViewData( selectedScreen, mergedInitialData, ); // Every path that changes the selection funnels through here, so this is // the one place the tab's memory of it has to be written. Reset included: // it re-selects the configured default, and that is what a later load // should resolve to. if (props.enableDefaultSaveInSession) { saveSelectedViewInSession( selectedScreen.id, props.config.uiElementGroupId, props.getUniqueViewId, ); } } // The rows the dropdowns render are rebuilt from the schema above, so the // values have to be put back after the applied query has been restored. mergedInitialData.quickFilter = restoreQuickFilterRowValues( mergedInitialData.quickFilter, ); props.onModelUpdate(null, props.config.uiElementGroupId, mergedInitialData); }; const loadUserViews = async () => { const screens = await loadScreensAction( props.config.uiElementGroupId, props.getUniqueViewId, ); const defaultPosition = screens.findIndex((s) => s.isDefault === true); const defaultView = _.cloneDeep(_defaultView); if (defaultPosition < 0) { defaultView.isDefault = true; defaultView.isSelected = true; } const deserializedScreens: View[] = [defaultView, ...screens]; const selectedScreen = resolveSelectedView(deserializedScreens, { uiElementGroupId: props.config.uiElementGroupId, getUniqueViewId: props.getUniqueViewId, enableDefaultSaveInSession: props.enableDefaultSaveInSession, }); if (selectedScreen) { // `deserializedScreenView` derives isSelected from the service's // `selected` flag, which is really "is default". When the tab's memory // wins over that, the list has to be re-flagged or the dropdown ticks the // default view while the toolbar renders the remembered one. const flaggedScreens = deserializedScreens.map((view: View) => ({ ...view, isSelected: view.id === selectedScreen.id, })); initScreenData(flaggedScreens, { ...selectedScreen, isSelected: true }); } }; const loadSelectedViewData = async (selectedView: View, initialData: any) => { let savedFilters; if ( selectedView.id === SYSTEM_DEFAULT && props.enableDefaultSaveInSession ) { savedFilters = getSavedSystemDefault( getSystemDefaultSessionKey( props.config.uiElementGroupId, props.getUniqueViewId, ), ); } else if (selectedView.id !== SYSTEM_DEFAULT) { savedFilters = await loadScreenByIdAction(selectedView.id); } if (savedFilters) { setSavedViewData(savedFilters); // Filter savedFilters to include only enableCustomView fields initialData = replaceCustomViewData( props.uiElementGroupData, initialData, savedFilters, props.toolBarOptions, ); } else { const dataToSave = filterCustomViewData( initialData, props.toolBarOptions, ); setSavedViewData(dataToSave); } return initialData; }; /** * The configuration a brand-new view starts from. * * `CURRENT` runs the same `filterCustomViewData` the Save button uses, so a * view created from the current screen is byte-identical to one created empty * and then saved. `COPY` reads the source view's persisted config from * wherever that view keeps it — session storage for System Default, the * metadata service for the rest — which is the same split `loadSelectedViewData` * applies when selecting a view. */ const resolveNewViewData = async ( options: NewViewOptions, ): Promise | null> => { if (options.source === NewViewSource.CURRENT) { return filterCustomViewData( props.uiElementGroupData, props.toolBarOptions, ); } // Copy sources are saved views only — System Default is filtered out of the // picker (see `copyableViews`), so there is no session-storage branch here. if (options.source === NewViewSource.COPY && options.copyFromViewId) { return await loadScreenByIdAction(options.copyFromViewId); } return null; }; const createUserView = async ( screenName: string, options: NewViewOptions, ) => { const newViewData = await resolveNewViewData(options); const { id, viewName, error } = await createScreenAction( screenName, props.config.uiElementGroupId, props.getUniqueViewId, newViewData, ); if (error) { setCreateViewError(error); return; } const systemDefaultView = props.uiElementGroupData.userView.allViews.find( (view: View) => view.id === SYSTEM_DEFAULT, ); const otherViews = props.uiElementGroupData.userView.allViews.filter( (view: View) => view.id !== SYSTEM_DEFAULT, ); // "Current setup" is create + save in one click, so it also lands the user // ON the new view — the same end state as pressing Save on an existing one. // Safe to select without reloading: the view was just saved FROM the screen, // so its configuration already equals what is applied. const selectNewView = options.source === NewViewSource.CURRENT; const newView = { id: id, isDefault: false, name: viewName, isSelected: selectNewView, }; const sortedOtherViews = [newView, ...otherViews] .map((view) => ({ ...view, isSelected: selectNewView ? view.id === id : view.isSelected, })) .sort((a, b) => a.name.localeCompare(b.name)); const deserializedScreens: View[] = systemDefaultView ? [ { ...systemDefaultView, isSelected: selectNewView ? false : systemDefaultView.isSelected, }, ...sortedOtherViews, ] : sortedOtherViews; if (selectNewView) { // Leaving System Default keeps its in-session snapshot, exactly as // switching views through the dropdown does. if ( props.uiElementGroupData.userView.selectedView?.id === SYSTEM_DEFAULT && props.enableDefaultSaveInSession ) { saveSystemDefaultInSession( props.config.uiElementGroupId, props.uiElementGroupData, props.toolBarOptions, props.getUniqueViewId, ); } // What was just persisted is now the saved baseline, so the unsaved-changes // indicator starts clean instead of flagging the view the moment it exists. setSavedViewData(newViewData); // This is the one selection change that does not go through // initScreenData, so the tab's memory is written here too. if (props.enableDefaultSaveInSession) { saveSelectedViewInSession( newView.id, props.config.uiElementGroupId, props.getUniqueViewId, ); } } const updatedUiElementGroupData = { ...props.uiElementGroupData, userView: { ...props.uiElementGroupData.userView, allViews: deserializedScreens, ...(selectNewView ? { selectedView: newView } : {}), }, }; props.onModelUpdate( null, props.config.uiElementGroupId, updatedUiElementGroupData, ); onViewToggle(); }; /** Preview of what a copy source would bring across, for the create popup. */ const loadViewSummary = async (viewId: string): Promise => { const savedData = await resolveNewViewData({ source: NewViewSource.COPY, copyFromViewId: viewId, }); return savedData ? summarizeSavedViewData( savedData, props.uiElementGroupData, props.toolBarOptions, ) : []; }; const saveUserView = async (id: string) => { await updateScreenAction( id, props.uiElementGroupData, props.toolBarOptions, ); // Update saved data after saving const savedFilters = await loadScreenByIdAction(id); setSavedViewData(savedFilters); }; const deleteUserView = async (id: string) => { await deleteScreenAction(id); const filteredViews = props.uiElementGroupData.userView.allViews.filter( (view: View) => view.id !== id, ); let selectedView = props.uiElementGroupData.userView.selectedView; if (selectedView?.id === id) { selectedView = { ..._defaultView, isSelected: true, isDefault: selectedView.isDefault, }; } initScreenData(filteredViews, selectedView, true); }; const setViewAsDefault = async (id: string) => { await markDefaultScreenAction( id, props.config.uiElementGroupId, props.getUniqueViewId, ); const updatedUserView = { ...props.uiElementGroupData.userView, allViews: props.uiElementGroupData.userView.allViews.map( (view: View) => ({ ...view, isDefault: view.id === id, }), ), }; const updatedUiElementGroupData = { ...props.uiElementGroupData, userView: updatedUserView, }; props.onModelUpdate( null, props.config.uiElementGroupId, updatedUiElementGroupData, ); }; /** The view whose name is being edited, or `null` when creating. */ const openRenameDialog = (view: View) => setViewToEdit(view); const closeRenameDialog = () => setViewToEdit(null); /** * Rename a saved view. * * Returns the server's message rather than throwing, so the dialog that asked * can show it against the field — a duplicate name is the expected failure * here, not an exceptional one, and it belongs next to the field that caused * it rather than in a toast over the list. * * On success the name is patched into `allViews` (and into `selectedView` * when it is the one renamed) instead of re-fetching: a rename touches one * field the client already knows, and reloading would close the popup the * user is still working in. */ const renameUserView = async (id: string, name: string): Promise => { const { error } = await renameScreenAction(id, name); if (error) return error; const userView = props.uiElementGroupData.userView; const updatedUserView = { ...userView, allViews: userView.allViews.map((view: View) => view.id === id ? { ...view, name } : view, ), selectedView: userView.selectedView?.id === id ? { ...userView.selectedView, name } : userView.selectedView, }; props.onModelUpdate(null, props.config.uiElementGroupId, { ...props.uiElementGroupData, userView: updatedUserView, }); return ""; }; const markAsSelected = async (view: View) => { const allViews = props.uiElementGroupData.userView.allViews.map( (v: View) => { return { ...v, isSelected: v.id === view.id }; }, ); const selectedView = { ...view, isSelected: true, }; if ( props.uiElementGroupData.userView.selectedView?.id === SYSTEM_DEFAULT && props.enableDefaultSaveInSession ) { saveSystemDefaultInSession( props.config.uiElementGroupId, props.uiElementGroupData, props.toolBarOptions, props.getUniqueViewId, ); } initScreenData(allViews, selectedView, true); }; const onViewToggle = () => { setTogglePopup((prevToggle) => !prevToggle); setCreateViewError(""); }; // System Default is not offered as a copy source: it is a per-user, partly // in-session baseline rather than a saved view, so "copy" of it means either // the screen defaults (already the Default setup option) or whatever happens // to sit in this tab's session storage. When no user views exist yet the list // is empty and the popup hides the Copy option entirely. const copyableViews = useMemo( () => (props.uiElementGroupData?.userView?.allViews ?? []).filter( (view: View) => view.id !== SYSTEM_DEFAULT, ), [props.uiElementGroupData?.userView?.allViews], ); // Only recomputed while the create popup is open — it is display-only text. const currentSummary = useMemo( () => togglePopup ? summarizeCustomViewData( props.uiElementGroupData, props.toolBarOptions, ) : [], [togglePopup, JSON.stringify(props.uiElementGroupData)], ); const hasChanges = useMemo( () => hasUnsavedChanges( props.uiElementGroupData, savedViewData, props.toolBarOptions, ), [JSON.stringify(props.uiElementGroupData), JSON.stringify(savedViewData)], ); return props.visible ? (
{/* One dialog, two modes. `viewToEdit` is what puts it in edit mode; the create path leaves it null. Keyed on the view being edited so switching rows re-seeds the name field instead of keeping the previous one — `screenName` is initialised from the prop, which only runs on mount. */} {(togglePopup || viewToEdit) && !props.disabled && ( )}
) : ( <> ); }; export default UserView;