diff --git a/src/Observers.ts b/src/Observers.ts index f03baa7..30097ce 100644 --- a/src/Observers.ts +++ b/src/Observers.ts @@ -1,12 +1,15 @@ import { takeoverExerciseContainer } from "./enhancements/ExerciseSelectorEnhancement"; import { monitorWeightContainer } from "./enhancements/ExerciseSetWeightEnhancement"; import { OnObserverDestroyFunct } from "./models/OnObserverDestroyFunct"; +import { takeoverWorkoutExerciseEditor } from "./enhancements/WorkoutExerciseEditorEnhancement"; const CHOSEN_PARENT_CONTAINER_SELECTOR = ".workout-name, .workout-step-exercises", WEIGHT_CONTAINER_SELECTOR = ".input-append.weight-entry", + WORKOUT_EXERCISE_CONTAINER_SELECTOR = "[class^='ExercisePicker_dropdown_']", CONTAINER_MAPPINGS: ReadonlyArray<[string, (parent: HTMLElement) => void]> = [ [CHOSEN_PARENT_CONTAINER_SELECTOR, addExerciseContainersFromParent], [WEIGHT_CONTAINER_SELECTOR, addWeightContainersFromParent], + [WORKOUT_EXERCISE_CONTAINER_SELECTOR, addExerciseContainersForWorkoutsFromParent], ], knownContainers: Map> = new Map(); @@ -43,6 +46,9 @@ function addExerciseContainersFromParent(parent: HTMLElement) { function addWeightContainersFromParent(parent: HTMLElement) { addGenericContainersFromParent(parent, WEIGHT_CONTAINER_SELECTOR, (container) => monitorWeightContainer(container)); } +function addExerciseContainersForWorkoutsFromParent(parent: HTMLElement) { + addGenericContainersFromParent(parent, WORKOUT_EXERCISE_CONTAINER_SELECTOR, (container) => takeoverWorkoutExerciseEditor(container)); +} function addGenericContainersFromParent(parent: HTMLElement, containerSelector: string, callback: (container: HTMLElement) => OnObserverDestroyFunct) { const containers = Array.from(parent.querySelectorAll(containerSelector)) as HTMLElement[]; diff --git a/src/components/ExerciseSelector.ts b/src/components/ExerciseSelector.ts index 171d9a6..1edefb8 100644 --- a/src/components/ExerciseSelector.ts +++ b/src/components/ExerciseSelector.ts @@ -1,9 +1,10 @@ -import { customElement, state } from "lit/decorators.js"; +import { customElement, property } from "lit/decorators.js"; import { css, html, LitElement } from "lit"; import ExerciseOption from "../models/ExerciseOption"; import ExerciseSelectorPopup from "./ExerciseSelectorPopup"; import ExerciseGroup from "../models/ExerciseGroup"; import { TypedLitElement } from "../models/TypedEventTarget"; +import ExerciseSelectorDelegate from "../models/ExerciseSelectorDelegate"; @customElement(ExerciseSelector.NAME) export default class ExerciseSelector extends (LitElement as TypedLitElement) { @@ -61,77 +62,32 @@ export default class ExerciseSelector extends (LitElement as TypedLitElement void; - disconnectErrorListener!: () => void; + @property() + savedOption: ExerciseOption | null = null; - constructor(readonly parentElem: HTMLElement) { + constructor( + delegate: ExerciseSelectorDelegate, + type: string, + canApplyToMultipleSets: boolean, + readonly parentElem: HTMLElement + ) { super(); - this.fromWorkoutEditor = parentElem.matches(".workout-step-exercises"); - this.parentSelectElem = parentElem.querySelector("select.chosen-select")!; - this.suggestedGroup = this.generateSuggestedGroup(); - this.type = ExerciseSelector.getType(this); - this.popupInstance = ExerciseSelectorPopup.getInstanceForSelector(this); - - this.findSavedOption(); - this.setupErrorListener(); - } - - private static getType(selector: ExerciseSelector) { - const option = selector.parentSelectElem.querySelector("optgroup:last-child > option:last-child") as HTMLOptionElement; - - return option.innerText; - } - - private generateSuggestedGroup(): ExerciseGroup { - const options = (Array.from(this.parentSelectElem.querySelectorAll("optgroup[label=\"Suggested\"] option")) as HTMLOptionElement[]) - .map((e) => new ExerciseOption(e)) - .sort((a, b) => a.textCleaned.localeCompare(b.textCleaned)); - - return new ExerciseGroup("Suggested", options); - } - - findSavedOption() { - const option = this.parentSelectElem.selectedOptions[0] ?? null; - if (!option) { - return; - } + this.canApplyToMultipleSets = canApplyToMultipleSets; + this.type = type; + this.delegate = delegate; - const exerciseOption = ExerciseOption.findExerciseOptionFromOptionElement(this.popupInstance.allOptions, option); - - if (exerciseOption) { - this.savedOption = exerciseOption; - } else { - console.warn("Failed to find the option instance for", option); - } - } - - setupErrorListener() { - const errorElem = this.parentElem.querySelector(".chosen-single")!; - - const observer = new MutationObserver(() => { - this.onError(errorElem.classList.contains("error-tooltip-active")); - }); - - this.connectErrorListener = () => observer.observe(errorElem, { attributes: true, attributeFilter: ["class"] }); - this.disconnectErrorListener = () => observer.disconnect(); + this.popupInstance = ExerciseSelectorPopup.getInstanceForSelector(this); } protected render(): unknown { @@ -145,21 +101,15 @@ export default class ExerciseSelector extends (LitElement as TypedLitElement { + return (Number(b.suggested) - Number(a.suggested)) || a.textCleaned.localeCompare(b.textCleaned); + }); + } } interface ExerciseSelectorEventMap { diff --git a/src/components/ExerciseSelectorPopup.ts b/src/components/ExerciseSelectorPopup.ts index 6f50f5c..59c8a94 100644 --- a/src/components/ExerciseSelectorPopup.ts +++ b/src/components/ExerciseSelectorPopup.ts @@ -29,7 +29,7 @@ export default class ExerciseSelectorPopup extends LitElement { let instance = this.INSTANCES.get(selector.type); if (!instance) { - instance = new ExerciseSelectorPopup(selector); + instance = new ExerciseSelectorPopup(selector.generateOptions()); this.INSTANCES.set(selector.type, instance); } @@ -158,11 +158,10 @@ export default class ExerciseSelectorPopup extends LitElement { @query("input") inputElem!: HTMLInputElement; @query(".options-container") optionsElem!: HTMLInputElement; - private constructor(initialSelector: ExerciseSelector) { + private constructor(options: ExerciseOption[]) { super(); - const selectElem = initialSelector.parentSelectElem; - this.options = this.generateOptions(selectElem); + this.options = options; this.allOptions = this.options; this.groups = this.generateOptionGroups(); this.populateOptionElements(); @@ -193,7 +192,7 @@ export default class ExerciseSelectorPopup extends LitElement {
) => this.applyMode = evt.detail}> = {}; - - return Array.from(selectElem.querySelectorAll("option")) - .filter((e) => !e.parentElement!.matches("[label='Suggested']")) - .map((e) => { - return new ExerciseOption(e); - }) - .filter((item) => { - const key = item.categoryValue + "~~~" + item.value; - return (item.value === ExerciseSelectorPopup.EMPTY_EXERCISE_VALUE || Object.prototype.hasOwnProperty.call(usedKeys, key)) ? false : (usedKeys[key] = true); - }) - .sort((a, b) => { - return (Number(b.suggested) - Number(a.suggested)) || a.textCleaned.localeCompare(b.textCleaned); - }); - } - private updateFilterVisibilityForOption(option: ExerciseOption) { option.updateFilterVisibility(this.activeMuscleGroupFilters, this.bodyweightFilter, this.favoritesFilter); } diff --git a/src/enhancements/ExerciseSelectorEnhancement.ts b/src/enhancements/ExerciseSelectorEnhancement.ts index ec816b5..2fdd407 100644 --- a/src/enhancements/ExerciseSelectorEnhancement.ts +++ b/src/enhancements/ExerciseSelectorEnhancement.ts @@ -1,11 +1,11 @@ import { OnObserverDestroyFunct } from "../models/OnObserverDestroyFunct"; -import ExerciseSelector from "../components/ExerciseSelector"; +import ExerciseSelectorBasicDelegate from "../models/ExerciseSelectorBasicDelegate"; export function takeoverExerciseContainer(container: HTMLElement): OnObserverDestroyFunct { try { - const exerciseSelector = new ExerciseSelector(container); + const basicExerciseSelector = new ExerciseSelectorBasicDelegate(container); - container.append(exerciseSelector); + container.append(basicExerciseSelector.exerciseSelector); } catch (e) { // do nothing, invalid element return false; diff --git a/src/enhancements/WorkoutExerciseEditorEnhancement.ts b/src/enhancements/WorkoutExerciseEditorEnhancement.ts new file mode 100644 index 0000000..103af71 --- /dev/null +++ b/src/enhancements/WorkoutExerciseEditorEnhancement.ts @@ -0,0 +1,19 @@ +import { OnObserverDestroyFunct } from "../models/OnObserverDestroyFunct"; +import ReactHelper from "../helpers/ReactHelper"; +import ExerciseSelectorReactDelegate from "../models/ExerciseSelectorReactDelegate"; + +export function takeoverWorkoutExerciseEditor(container: HTMLElement): OnObserverDestroyFunct { + try { + const props = ReactHelper.closestProps(container, ["flattenedExerciseTypes", "onChange"], 5); + + if (props) { + const reactExerciseSelector = new ExerciseSelectorReactDelegate(props, container); + container.parentElement!.append(reactExerciseSelector.exerciseSelector); + } + } catch (e) { + // do nothing, invalid element + return false; + } + + return () => {}; +} diff --git a/src/helpers/ReactHelper.ts b/src/helpers/ReactHelper.ts new file mode 100644 index 0000000..07362af --- /dev/null +++ b/src/helpers/ReactHelper.ts @@ -0,0 +1,71 @@ +export default class ReactHelper { + static closestProps( + elem: HTMLElement, + propKeys: string[], + maxDepth: number + ): Record | null { + let currentParent: HTMLElement | null = elem; + do { + const reactFiberKey = this.getReactFiberKey(currentParent); + + if (reactFiberKey && this.isReactFiber(currentParent[reactFiberKey])) { + const memoizedProps = (currentParent[reactFiberKey] as unknown as ReactFiber).memoizedProps, + matchingProps = this.closestPropsRecursive(memoizedProps, propKeys); + + if (matchingProps) { + return matchingProps; + } + } + + currentParent = currentParent.parentElement; + maxDepth--; + } while (currentParent && maxDepth > 0); + + return null; + } + + private static closestPropsRecursive(props: ReactProps, propKeys: string[]): Record | null { + if ( + "props" in props && + propKeys.filter((e) => e in props.props).length === propKeys.length + ) { + return props.props; + } + + if ("children" in props && props.children) { + const childProps = Array.isArray(props.children) ? props.children : [props.children]; + + for (const props of childProps) { + if (typeof props === "object" && props && !Array.isArray(props)) { + const value = this.closestPropsRecursive(props, propKeys); + + if (value) { + return value; + } + } + } + } + + return null; + } + + private static getReactFiberKey(elem: T): keyof T | null { + const objKeys = Object.keys(elem) as (keyof T)[]; + + return objKeys.find((e) => (e as string).startsWith("__reactFiber")) || null; + } + + private static isReactFiber(obj: unknown): obj is ReactFiber { + return Boolean(typeof obj === "object" && obj && !Array.isArray(obj) && + "memoizedProps" in obj && typeof obj.memoizedProps === "object" && !Array.isArray(obj.memoizedProps) && obj.memoizedProps && + "children" in obj.memoizedProps && typeof obj.memoizedProps.children === "object"); + } +} + +type ReactFiber = { + memoizedProps: ReactProps; +}; +type ReactProps = { + children?: ReactProps | ReactProps[]; + props: Record & ReactProps; +}; diff --git a/src/models/BasicExerciseOption.ts b/src/models/BasicExerciseOption.ts new file mode 100644 index 0000000..177b976 --- /dev/null +++ b/src/models/BasicExerciseOption.ts @@ -0,0 +1,31 @@ +import ExerciseOption from "./ExerciseOption"; + +export default class BasicExerciseOption extends ExerciseOption { + constructor(optionElem: HTMLOptionElement) { + super( + BasicExerciseOption.findValue(optionElem) || "", + BasicExerciseOption.findCategory(optionElem) || "", + optionElem.innerText, + optionElem.parentElement!.matches("[label='Suggested']") + ); + } + + static findExerciseOption(exerciseOptions: readonly ExerciseOption[], option: HTMLOptionElement): ExerciseOption | null { + const value = this.findValue(option), + category = this.findCategory(option); + + if (!value || !category) { + return null; + } + + return exerciseOptions.find((e) => e.value === value && e.categoryValue === category) ?? null; + } + + private static findValue(option: HTMLOptionElement): string { + return option.value; + } + + private static findCategory(option: HTMLOptionElement): string | null { + return option.dataset["exerciseCategory"] ?? null; + } +} diff --git a/src/models/ExerciseOption.ts b/src/models/ExerciseOption.ts index 02de9ee..268ce3c 100644 --- a/src/models/ExerciseOption.ts +++ b/src/models/ExerciseOption.ts @@ -18,7 +18,6 @@ export default class ExerciseOption { favorited: boolean; elem!: ExerciseSelectorOption; - readonly optionElem: HTMLOptionElement; private _visible: boolean = true; get visible(): boolean { @@ -49,13 +48,18 @@ export default class ExerciseOption { this.elem.underlinedValue = this.underlinedText; } - constructor(optionElem: HTMLOptionElement) { - this.value = ExerciseOption.findValueFromOptionElement(optionElem) || ""; - this.categoryValue = ExerciseOption.findCategoryFromOptionElement(optionElem) || ""; - this.text = optionElem.innerText.trim(); + protected constructor( + value: string, + categoryValue: string, + text: string, + suggested: boolean + ) { + this.value = value; + this.categoryValue = categoryValue; + this.text = text.trim(); + this.suggested = suggested; + this.textCleaned = SearchHelper.clean(this.text); - this.optionElem = optionElem; - this.suggested = optionElem.parentElement!.matches("[label='Suggested']"); this.favorited = FavoritesService.INSTANCE.hasFavorite(this.categoryValue, this.value); } @@ -145,23 +149,4 @@ export default class ExerciseOption { return option.textCleaned.charAt(0); } } - - static findExerciseOptionFromOptionElement(exerciseOptions: readonly ExerciseOption[], option: HTMLOptionElement): ExerciseOption | null { - const value = this.findValueFromOptionElement(option), - category = this.findCategoryFromOptionElement(option); - - if (!value || !category) { - return null; - } - - return exerciseOptions.find((e) => e.value === value && e.categoryValue === category) ?? null; - } - - private static findValueFromOptionElement(option: HTMLOptionElement): string { - return option.value; - } - - private static findCategoryFromOptionElement(option: HTMLOptionElement): string | null { - return option.dataset["exerciseCategory"] ?? null; - } } diff --git a/src/models/ExerciseSelectorBasicDelegate.ts b/src/models/ExerciseSelectorBasicDelegate.ts new file mode 100644 index 0000000..af4e935 --- /dev/null +++ b/src/models/ExerciseSelectorBasicDelegate.ts @@ -0,0 +1,104 @@ +import ExerciseSelector from "../components/ExerciseSelector"; +import ExerciseOption from "./ExerciseOption"; +import ExerciseSelectorDelegate from "./ExerciseSelectorDelegate"; +import ExerciseGroup from "./ExerciseGroup"; +import ExerciseSelectorPopup from "../components/ExerciseSelectorPopup"; +import BasicExerciseOption from "./BasicExerciseOption"; + +export default class ExerciseSelectorBasicDelegate implements ExerciseSelectorDelegate { + readonly parentSelectElem: HTMLSelectElement; + readonly exerciseSelector: ExerciseSelector; + + readonly suggestedGroup: ExerciseGroup; + + private connectErrorListener!: () => void; + private disconnectErrorListener!: () => void; + + constructor(readonly container: HTMLElement) { + this.parentSelectElem = container.querySelector("select.chosen-select")!; + this.suggestedGroup = this.generateSuggestedGroup(); + + const type = (this.parentSelectElem.querySelector("optgroup:last-child > option:last-child") as HTMLOptionElement).innerText, + canApplyToMultipleSets = !this.parentSelectElem.matches(".workout-step-exercises"); + + this.exerciseSelector = new ExerciseSelector(this, type, canApplyToMultipleSets, container); + this.exerciseSelector.savedOption = this.getInitialSavedOption(); + + this.initErrorHandler(); + } + + private initErrorHandler() { + const errorElem = this.container.querySelector(".chosen-single")!; + + const observer = new MutationObserver(() => { + this.exerciseSelector.onError(errorElem.classList.contains("error-tooltip-active")); + }); + + this.connectErrorListener = () => observer.observe(errorElem, { attributes: true, attributeFilter: ["class"] }); + this.disconnectErrorListener = () => observer.disconnect(); + } + + private getInitialSavedOption(): ExerciseOption | null { + const option = this.parentSelectElem.selectedOptions[0] ?? null; + if (!option) { + return null; + } + + const exerciseOption = BasicExerciseOption.findExerciseOption(this.exerciseSelector.popupInstance.allOptions, option); + if (!exerciseOption) { + console.warn("Failed to find the option instance for", option); + } + + return exerciseOption; + } + + onConnected() { + this.connectErrorListener?.(); + this.hideGarminElements(); + } + + onDisconnected() { + this.disconnectErrorListener?.(); + } + + private hideGarminElements() { + this.container.querySelector(".chosen-container.chosen-container-single")!.setAttribute("style", "opacity: 0;pointer-events: none;height: 0px;position: relative;max-width: none;width: 100%;display: block;"); + } + + private generateSuggestedGroup(): ExerciseGroup { + const options = (Array.from(this.parentSelectElem.querySelectorAll("optgroup[label=\"Suggested\"] option")) as HTMLOptionElement[]) + .map((e) => new BasicExerciseOption(e)) + .sort((a, b) => a.textCleaned.localeCompare(b.textCleaned)); + + return new ExerciseGroup("Suggested", options); + } + + generateOptions(): ExerciseOption[] { + const usedKeys: Record = {}; + + return Array.from(this.parentSelectElem.querySelectorAll("option")) + .filter((e) => !e.parentElement!.matches("[label='Suggested']")) + .map((e) => { + return new BasicExerciseOption(e); + }) + .filter((item) => { + const key = item.categoryValue + "~~~" + item.value; + return (item.value === ExerciseSelectorPopup.EMPTY_EXERCISE_VALUE || Object.prototype.hasOwnProperty.call(usedKeys, key)) ? false : (usedKeys[key] = true); + }); + } + + onSelectOption(option: ExerciseOption | null): boolean { + const optionElemIndex = !option ? -1 : option.findOptionFromSelectElement(this.parentSelectElem)?.index; + + if (optionElemIndex !== undefined) { + this.parentSelectElem.selectedIndex = optionElemIndex; + this.parentSelectElem.dispatchEvent(new Event("change")); + + return true; + } else { + console.warn("Failed to find the option element for", option); + + return false; + } + } +} diff --git a/src/models/ExerciseSelectorDelegate.ts b/src/models/ExerciseSelectorDelegate.ts new file mode 100644 index 0000000..11fe5ac --- /dev/null +++ b/src/models/ExerciseSelectorDelegate.ts @@ -0,0 +1,15 @@ +import ExerciseOption from "./ExerciseOption"; +import ExerciseGroup from "./ExerciseGroup"; +import ExerciseSelector from "../components/ExerciseSelector"; + +export default interface ExerciseSelectorDelegate { + readonly exerciseSelector: ExerciseSelector; + readonly suggestedGroup: ExerciseGroup; + + onConnected(): void; + onDisconnected(): void; + + generateOptions(): ExerciseOption[]; + + onSelectOption(option: ExerciseOption | null): boolean; +} diff --git a/src/models/ExerciseSelectorReactDelegate.ts b/src/models/ExerciseSelectorReactDelegate.ts new file mode 100644 index 0000000..83968f7 --- /dev/null +++ b/src/models/ExerciseSelectorReactDelegate.ts @@ -0,0 +1,104 @@ +import ExerciseSelector from "../components/ExerciseSelector"; +import ExerciseGroup from "./ExerciseGroup"; +import ExerciseOption from "./ExerciseOption"; +import ExerciseSelectorDelegate from "./ExerciseSelectorDelegate"; +import ReactExerciseOption from "./ReactExerciseOption"; +import { + isRawReactExerciseOption, + isRawReactExercisePickerProps, + RawReactExerciseOption, + RawReactExercisePickerProps +} from "./ReactModels"; + +export default class ExerciseSelectorReactDelegate implements ExerciseSelectorDelegate { + readonly exerciseSelector: ExerciseSelector; + readonly suggestedGroup: ExerciseGroup; + readonly reactProps: RawReactExercisePickerProps; + + private connectErrorListener?: () => void; + private disconnectErrorListener?: () => void; + + constructor(reactProps: Record, readonly container: HTMLElement) { + if (!isRawReactExercisePickerProps(reactProps)) { + throw new Error("Invalid react props given, missing required keys"); + } + this.reactProps = reactProps; + + this.exerciseSelector = new ExerciseSelector(this, ExerciseSelectorReactDelegate.getType(reactProps), false, container); + this.exerciseSelector.savedOption = this.getInitialSavedOption(); + this.suggestedGroup = new ExerciseGroup("Suggested"); + + this.initErrorHandler(); + } + + private initErrorHandler() { + const inputWrapperElem = this.container.closest("[class^='GarminInputWrapper_garminInputWrapper']"); + if (inputWrapperElem) { + const onChildUpdate = () => { + const hasValidationError = Boolean(inputWrapperElem.querySelector("[class^='GarminInputWrapper_validationError']")); + this.exerciseSelector.onError(hasValidationError); + }, + observer = new MutationObserver(onChildUpdate); + + this.connectErrorListener = () => { + observer.observe(inputWrapperElem, { childList: true }); + onChildUpdate(); + }; + this.disconnectErrorListener = () => observer.disconnect(); + + onChildUpdate(); + } + } + + private static getType(reactProps: Record): string { + return `${"exerciseType" in reactProps ? reactProps.exerciseType : ""}:${(reactProps.flattenedExerciseTypes as Array).length}`; + } + + private getInitialSavedOption(): ExerciseOption | null { + const reactProps = this.reactProps; + if ( + "categoryKey" in reactProps && typeof reactProps.categoryKey === "string" && + "exerciseKey" in reactProps && typeof reactProps.exerciseKey === "string" + ) { + const exerciseOption = ReactExerciseOption.findExerciseOption(this.exerciseSelector.popupInstance.allOptions, reactProps.categoryKey, reactProps.exerciseKey); + if (!exerciseOption) { + console.warn("Failed to find the option instance for", this.reactProps); + } + + return exerciseOption; + } + + return null; + } + + onConnected(): void { + this.hideGarminElements(); + this.connectErrorListener?.(); + } + + onDisconnected(): void { + this.disconnectErrorListener?.(); + } + + private hideGarminElements() { + this.container.setAttribute("style", "opacity: 0;pointer-events: none;height: 0px;position: relative;max-width: none;width: 100%;display: block;"); + } + + generateOptions(): ExerciseOption[] { + return (this.reactProps.flattenedExerciseTypes as RawReactExerciseOption[]) + .filter((e) => isRawReactExerciseOption(e)) + .map((e) => { + return new ReactExerciseOption(e); + }); + } + + onSelectOption(option: ExerciseOption | null): boolean { + if (option) { + this.reactProps.onChange({ categoryKey: option.categoryValue, exerciseKey: option.value }); + + return true; + } + + return false; + } +} diff --git a/src/models/ReactExerciseOption.ts b/src/models/ReactExerciseOption.ts new file mode 100644 index 0000000..dfe7304 --- /dev/null +++ b/src/models/ReactExerciseOption.ts @@ -0,0 +1,17 @@ +import ExerciseOption from "./ExerciseOption"; +import { RawReactExerciseOption } from "./ReactModels"; + +export default class ReactExerciseOption extends ExerciseOption { + constructor(obj: RawReactExerciseOption) { + super( + obj.exerciseKey, + obj.categoryKey, + obj.exerciseName, + false + ); + } + + static findExerciseOption(exerciseOptions: readonly ExerciseOption[], categoryKey: string, exerciseKey: string): ExerciseOption | null { + return exerciseOptions.find((e) => e.value === exerciseKey && e.categoryValue === categoryKey) ?? null; + } +} diff --git a/src/models/ReactModels.ts b/src/models/ReactModels.ts new file mode 100644 index 0000000..ed89f6e --- /dev/null +++ b/src/models/ReactModels.ts @@ -0,0 +1,29 @@ +export interface RawReactExerciseOption { + categoryKey: string; + exerciseKey: string; + exerciseName: string; +} + +export function isRawReactExerciseOption(obj: unknown): obj is RawReactExerciseOption { + return Boolean( + typeof obj === "object" && obj && !Array.isArray(obj) && + "categoryKey" in obj && typeof obj.categoryKey === "string" && + "exerciseKey" in obj && typeof obj.exerciseKey === "string" && + "exerciseName" in obj && typeof obj.exerciseName === "string" + ); +} + +export interface RawReactExercisePickerProps { + categoryKey?: string; + exerciseKey?: string; + flattenedExerciseTypes: Array; + onChange: ((changed: { categoryKey: string; exerciseKey: string }) => void); +} + +export function isRawReactExercisePickerProps(obj: unknown): obj is RawReactExercisePickerProps { + return Boolean( + typeof obj === "object" && obj && !Array.isArray(obj) && + "flattenedExerciseTypes" in obj && Array.isArray(obj.flattenedExerciseTypes) && + "onChange" in obj && typeof obj.onChange === "function" + ); +}