@redocly/theme
Version:
Shared UI components lib
295 lines (249 loc) • 8.92 kB
text/typescript
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,
};
}