UNPKG

@redocly/theme

Version:

Shared UI components lib

295 lines (249 loc) 8.92 kB
import { useState, useEffect, useMemo, useRef } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import type { CodeWalkthroughFile, CodeWalkthroughFilter, InputsMarkdocAttr, TogglesMarkdocAttr, CodeWalkthroughControls, CodeWalkthroughFilterItem, CodeWalkthroughConditionsObject, CodeWalkthroughControlsState, } from '@redocly/config'; import { downloadCodeWalkthrough } from '../../utils/download-code-walkthrough'; import { matchCodeWalkthroughConditions } from '../../utils/match-code-walkthrough-conditions'; import { getCodeWalkthroughFileText } from '../../utils/get-code-walkthrough-file-text'; import { replaceInputsWithValue } from '../../utils/replace-inputs-with-value'; export type ActiveFilter = { id: string; label?: string; items: CodeWalkthroughFilterItem[]; }; export type WalkthroughControlsState = { activeFilters: ActiveFilter[]; getControlState: (id: string) => { value: string | boolean; render: boolean } | null; changeControlState: (id: string, value: string | boolean) => void; /* Utility */ areConditionsMet: (conditions: CodeWalkthroughConditionsObject) => boolean; handleDownloadCode: (files: CodeWalkthroughFile[]) => Promise<void>; getFileText: (file: CodeWalkthroughFile) => string; populateInputsWithValue: (node: string) => string; }; const defaultControlsValues: CodeWalkthroughControls = { input: '', toggle: false, filter: '', }; export function useCodeWalkthroughControls( filters: Record<string, CodeWalkthroughFilter>, inputs: InputsMarkdocAttr, toggles: TogglesMarkdocAttr, enableDeepLink: boolean, ): WalkthroughControlsState { const location = useLocation(); const navigate = useNavigate(); const searchParams = useMemo(() => new URLSearchParams(location.search), [location.search]); const filtersRef = useRef(filters); const inputsRef = useRef(inputs); const togglesRef = useRef(toggles); const getInitialState = () => { const state: CodeWalkthroughControlsState = {}; for (const [id, toggle] of Object.entries(toggles)) { state[id] = { ...toggle, render: true, type: 'toggle', value: enableDeepLink ? searchParams.get(id) === 'true' : false, }; } for (const [id, input] of Object.entries(inputs)) { state[id] = { ...input, render: true, type: 'input', value: enableDeepLink ? (searchParams.get(id) ?? input.value) : input.value, }; } for (const [id, filter] of Object.entries(filters)) { const defaultValue = filter?.items?.[0]?.value || ''; state[id] = { ...filter, render: true, type: 'filter', value: enableDeepLink ? (searchParams.get(id) ?? defaultValue) : defaultValue, }; } return state; }; const [controlsState, setControlsState] = useState(getInitialState); useEffect(() => { const sameProps = [ JSON.stringify(filters) === JSON.stringify(filtersRef.current), JSON.stringify(inputs) === JSON.stringify(inputsRef.current), JSON.stringify(toggles) === JSON.stringify(togglesRef.current), ]; if (sameProps.every(Boolean)) { return; } filtersRef.current = filters; inputsRef.current = inputs; togglesRef.current = toggles; const newState = getInitialState(); // Preserve existing values where control type hasn't changed Object.entries(newState).forEach(([id, control]) => { const existingControl = controlsState[id]; if (existingControl && existingControl.type === control.type) { // @ts-ignore newState[id] = { ...control, value: existingControl.value, }; } }); setControlsState(newState); // eslint-disable-next-line react-hooks/exhaustive-deps }, [filters, inputs, toggles, enableDeepLink]); const changeControlState = (id: string, value: string | boolean) => { setControlsState((prev) => { const control = prev[id]; if (!control) { console.error(`Control with id "${id}" not found.`); return prev; } switch (control.type) { case 'input': if (typeof value !== 'string') { console.error( `Invalid value type for input "${id}". Input control type requires a string value.`, ); return prev; } break; case 'toggle': if (typeof value !== 'boolean') { console.error( `Invalid value type for toggle "${id}". Toggle control type requires a boolean value.`, ); return prev; } break; case 'filter': if (typeof value !== 'string') { console.error( `Invalid value type for filter "${id}". Filter control type requires a string value.`, ); return prev; } break; default: console.error( `Invalid control type "${(control as { type: string })?.type}" for control "${id}". Allowed types are "toggle", "input", or "filter".`, ); return prev; } return { ...prev, [id]: { ...control, value, }, } as CodeWalkthroughControlsState; }); }; const getControlState = (id: string) => { const controlState = controlsState[id]; if (controlState) { return { render: controlState.render, value: controlState.value, }; } else { return null; } }; const walkthroughContext = useMemo(() => { const areConditionsMet = (conditions: CodeWalkthroughConditionsObject) => matchCodeWalkthroughConditions(conditions, controlsState); // reset controls for (const control of Object.values(controlsState)) { control.render = true; control.value = control.value || defaultControlsValues[control.type]; } for (const [id, control] of Object.entries(controlsState)) { if (control && !areConditionsMet(control)) { controlsState[id].render = false; controlsState[id].value = defaultControlsValues[control.type]; } } const activeFilters = []; for (const [id, filter] of Object.entries(filters)) { if (!controlsState[id].render) { continue; } // eslint-disable-next-line no-warning-comments // code-walk-todo: need to check if we have a default fallback const items = Array.isArray(filter?.items) ? filter.items : []; const activeItems = items.filter((item) => areConditionsMet(item)); if (activeItems.length === 0) { controlsState[id].render = false; controlsState[id].value = defaultControlsValues['filter']; continue; } const currentValue = controlsState[id].value; if (currentValue) { const isValueInActiveItems = activeItems.findIndex(({ value }) => value === currentValue) !== -1; controlsState[id].value = isValueInActiveItems ? currentValue : activeItems[0].value; } else { controlsState[id].value = activeItems[0].value; } activeFilters.push({ id, label: filter.label, items: activeItems, }); } const inputsState = Object.fromEntries( Object.entries(controlsState).filter(([_, controlState]) => controlState.type === 'input'), ) as Record<string, { value: string }>; const handleDownloadCode = (files: CodeWalkthroughFile[]) => downloadCodeWalkthrough(files, controlsState, inputsState); const getFileText = (file: CodeWalkthroughFile) => getCodeWalkthroughFileText(file, controlsState, inputsState); const populateInputsWithValue = (node: string) => replaceInputsWithValue(node, inputsState); return { activeFilters, areConditionsMet, handleDownloadCode, getFileText, populateInputsWithValue, }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [controlsState]); /** * Update the URL search params with the current state of the filters and inputs */ useEffect(() => { if (!enableDeepLink) { return; } const newSearchParams = new URLSearchParams(Array.from(searchParams.entries())); for (const [id, { value }] of Object.entries(controlsState)) { if (value) { newSearchParams.set(id, value.toString()); } else { newSearchParams.delete(id); } } const newSearch = newSearchParams.toString(); if (newSearch === location.search.substring(1)) return; navigate({ search: newSearch }, { replace: true }); // Ignore searchParams in dependency array to avoid infinite re-renders // eslint-disable-next-line react-hooks/exhaustive-deps }, [controlsState]); return { changeControlState, getControlState, ...walkthroughContext, }; }