Skip to content

Commit

Permalink
feat: store search keywords and sidebar tab in URL search params
Browse files Browse the repository at this point in the history
Also moves useStateWithUrlSearchParam into hooks.ts so it can be used
outside of SearchManager.
  • Loading branch information
pomegranited committed Dec 18, 2024
1 parent 8706bf6 commit 8fdbe9b
Show file tree
Hide file tree
Showing 6 changed files with 102 additions and 63 deletions.
51 changes: 49 additions & 2 deletions src/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { useEffect, useState } from 'react';
import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useState,
} from 'react';
import { history } from '@edx/frontend-platform';
import { useLocation } from 'react-router-dom';
import { useLocation, useSearchParams } from 'react-router-dom';

export const useScrollToHashElement = ({ isLoading }: { isLoading: boolean }) => {
const [elementWithHash, setElementWithHash] = useState<string | null>(null);
Expand Down Expand Up @@ -77,3 +83,44 @@ export const useLoadOnScroll = (
return () => { };
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
};

/**
* Hook which stores state variables in the URL search parameters.
*
* It wraps useState with functions that get/set a query string
* search parameter when returning/setting the state variable.
*
* @param defaultValue: Type
* Returned when no valid value is found in the url search parameter.
* @param paramName: name of the url search parameter to store this value in.
* @param fromString: returns the Type equivalent of the given string value,
* or undefined if the value is invalid.
* @param toString: returns the string equivalent of the given Type value.
* Return defaultValue to clear the url search paramName.
*/
export function useStateWithUrlSearchParam<Type>(
defaultValue: Type,
paramName: string,
fromString: (value: string | null) => Type | undefined,
toString: (value: Type) => string | undefined,
): [value: Type, setter: Dispatch<SetStateAction<Type>>] {
const [searchParams, setSearchParams] = useSearchParams();
const returnValue: Type = fromString(searchParams.get(paramName)) ?? defaultValue;
// Function to update the url search parameter
const returnSetter: Dispatch<SetStateAction<Type>> = useCallback((value: Type) => {
setSearchParams((prevParams) => {
const paramValue: string = toString(value) ?? '';
const newSearchParams = new URLSearchParams(prevParams);
// If using the default paramValue, remove it from the search params.
if (paramValue === defaultValue) {
newSearchParams.delete(paramName);
} else {
newSearchParams.set(paramName, paramValue);
}
return newSearchParams;
}, { replace: true });
}, [setSearchParams]);

// Return the computed value and wrapped set state function
return [returnValue, returnSetter];
}
12 changes: 4 additions & 8 deletions src/library-authoring/LibraryAuthoringPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -431,27 +431,23 @@ describe('<LibraryAuthoringPage />', () => {
// Click on the first collection
fireEvent.click((await screen.findByText('Collection 1')));

const sidebar = screen.getByTestId('library-sidebar');

const { getByRole } = within(sidebar);

// Click on the Details tab
fireEvent.click(getByRole('tab', { name: 'Details' }));
fireEvent.click(screen.getByRole('tab', { name: 'Details' }));

// Change to a component
fireEvent.click((await screen.findAllByText('Introduction to Testing'))[0]);

// Check that the Details tab is still selected
expect(getByRole('tab', { name: 'Details' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: 'Details' })).toHaveAttribute('aria-selected', 'true');

// Click on the Previews tab
fireEvent.click(getByRole('tab', { name: 'Preview' }));
fireEvent.click(screen.getByRole('tab', { name: 'Preview' }));

// Switch back to the collection
fireEvent.click((await screen.findByText('Collection 1')));

// The Manage (default) tab should be selected because the collection does not have a Preview tab
expect(getByRole('tab', { name: 'Manage' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: 'Manage' })).toHaveAttribute('aria-selected', 'true');
});

it('can filter by capa problem type', async () => {
Expand Down
6 changes: 3 additions & 3 deletions src/library-authoring/collections/CollectionInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ const CollectionInfo = () => {

const { componentPickerMode } = useComponentPickerContext();
const { libraryId, setCollectionId } = useLibraryContext();
const { sidebarComponentInfo, setSidebarCurrentTab } = useSidebarContext();
const { sidebarComponentInfo, sidebarCurrentTab, setSidebarCurrentTab } = useSidebarContext();

const tab: CollectionInfoTab = (
sidebarComponentInfo?.currentTab && isCollectionInfoTab(sidebarComponentInfo.currentTab)
) ? sidebarComponentInfo?.currentTab : COLLECTION_INFO_TABS.Manage;
sidebarCurrentTab && isCollectionInfoTab(sidebarCurrentTab)
) ? sidebarCurrentTab : COLLECTION_INFO_TABS.Manage;

const collectionId = sidebarComponentInfo?.id;
// istanbul ignore if: this should never happen
Expand Down
25 changes: 19 additions & 6 deletions src/library-authoring/common/context/SidebarContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
useMemo,
useState,
} from 'react';
import { useStateWithUrlSearchParam } from '../../../hooks';

export enum SidebarBodyComponentId {
AddContent = 'add-content',
Expand Down Expand Up @@ -32,13 +33,18 @@ export const isComponentInfoTab = (tab: string): tab is ComponentInfoTab => (
Object.values<string>(COMPONENT_INFO_TABS).includes(tab)
);

type SidebarInfoTab = ComponentInfoTab | CollectionInfoTab;
const toSidebarInfoTab = (tab: string): SidebarInfoTab | undefined => (
isComponentInfoTab(tab) || isCollectionInfoTab(tab)
? tab : undefined
);

export interface SidebarComponentInfo {
type: SidebarBodyComponentId;
id: string;
/** Additional action on Sidebar display */
// TODO -- split into own state
additionalAction?: SidebarAdditionalActions;
/** Current tab in the sidebar */
currentTab?: CollectionInfoTab | ComponentInfoTab;
}

export enum SidebarAdditionalActions {
Expand All @@ -54,6 +60,7 @@ export type SidebarContextData = {
openComponentInfoSidebar: (usageKey: string, additionalAction?: SidebarAdditionalActions) => void;
sidebarComponentInfo?: SidebarComponentInfo;
resetSidebarAdditionalActions: () => void;
sidebarCurrentTab: SidebarInfoTab;
setSidebarCurrentTab: (tab: CollectionInfoTab | ComponentInfoTab) => void;
};

Expand Down Expand Up @@ -82,6 +89,13 @@ export const SidebarProvider = ({
initialSidebarComponentInfo,
);

const [sidebarCurrentTab, setSidebarCurrentTab] = useStateWithUrlSearchParam<SidebarInfoTab>(
COMPONENT_INFO_TABS.Preview,
'st',
(value: string) => toSidebarInfoTab(value),
(value: SidebarInfoTab) => value.toString(),
);

/** Helper function to consume additional action once performed.
Required to redo the action.
*/
Expand Down Expand Up @@ -130,10 +144,6 @@ export const SidebarProvider = ({
}
}, []);

const setSidebarCurrentTab = useCallback((tab: CollectionInfoTab | ComponentInfoTab) => {
setSidebarComponentInfo((prev) => (prev && { ...prev, currentTab: tab }));
}, []);

const context = useMemo<SidebarContextData>(() => {
const contextValue = {
closeLibrarySidebar,
Expand All @@ -144,6 +154,7 @@ export const SidebarProvider = ({
sidebarComponentInfo,
openCollectionInfoSidebar,
resetSidebarAdditionalActions,
sidebarCurrentTab,
setSidebarCurrentTab,
};

Expand All @@ -157,6 +168,7 @@ export const SidebarProvider = ({
sidebarComponentInfo,
openCollectionInfoSidebar,
resetSidebarAdditionalActions,
sidebarCurrentTab,
setSidebarCurrentTab,
]);

Expand All @@ -179,6 +191,7 @@ export function useSidebarContext(): SidebarContextData {
openComponentInfoSidebar: () => {},
openCollectionInfoSidebar: () => {},
resetSidebarAdditionalActions: () => {},
sidebarCurrentTab: COMPONENT_INFO_TABS.Preview,
setSidebarCurrentTab: () => {},
sidebarComponentInfo: undefined,
};
Expand Down
13 changes: 10 additions & 3 deletions src/library-authoring/component-info/ComponentInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,20 @@ const ComponentInfo = () => {
const intl = useIntl();

const { readOnly, openComponentEditor } = useLibraryContext();
const { setSidebarCurrentTab, sidebarComponentInfo, resetSidebarAdditionalActions } = useSidebarContext();
const {
sidebarCurrentTab,
setSidebarCurrentTab,
sidebarComponentInfo,
resetSidebarAdditionalActions,
} = useSidebarContext();

const jumpToCollections = sidebarComponentInfo?.additionalAction === SidebarAdditionalActions.JumpToAddCollections;

const tab: ComponentInfoTab = (
sidebarComponentInfo?.currentTab && isComponentInfoTab(sidebarComponentInfo.currentTab)
) ? sidebarComponentInfo?.currentTab : COMPONENT_INFO_TABS.Preview;
isComponentInfoTab(sidebarCurrentTab)
? sidebarCurrentTab
: COMPONENT_INFO_TABS.Preview
);

useEffect(() => {
// Show Manage tab if JumpToAddCollections action is set in sidebarComponentInfo
Expand Down
58 changes: 17 additions & 41 deletions src/search-manager/SearchManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
* https://github.com/algolia/instantsearch/issues/1658
*/
import React from 'react';
import { useSearchParams } from 'react-router-dom';
import { MeiliSearch, type Filter } from 'meilisearch';
import { union } from 'lodash';

import {
CollectionHit, ContentHit, SearchSortOption, forceArray,
} from './data/api';
import { useContentSearchConnection, useContentSearchResults } from './data/apiHooks';
import { useStateWithUrlSearchParam } from '../hooks';

export interface SearchContextData {
client?: MeiliSearch;
Expand Down Expand Up @@ -47,45 +47,6 @@ export interface SearchContextData {

const SearchContext = React.createContext<SearchContextData | undefined>(undefined);

/**
* Hook which lets you store state variables in the URL search parameters.
*
* It wraps useState with functions that get/set a query string
* search parameter when returning/setting the state variable.
*
*/
function useStateWithUrlSearchParam<Type>(
defaultValue: Type,
paramName: string,
// Returns the Type equivalent of the given string value, or
// undefined if value is invalid.
fromString: (value: string | null) => Type | undefined,
// Returns the string equivalent of the given Type value.
// Returning empty string/undefined will clear the url search paramName.
toString: (value: Type) => string | undefined,
): [value: Type, setter: React.Dispatch<React.SetStateAction<Type>>] {
const [searchParams, setSearchParams] = useSearchParams();
// The converted search parameter value takes precedence over the state value.
const returnValue: Type = fromString(searchParams.get(paramName)) ?? defaultValue;
// Function to update the url search parameter
const returnSetter: React.Dispatch<React.SetStateAction<Type>> = React.useCallback((value: Type) => {
setSearchParams((prevParams) => {
const paramValue: string = toString(value) ?? '';
const newSearchParams = new URLSearchParams(prevParams);
// If using the default paramValue, remove it from the search params.
if (paramValue === defaultValue) {
newSearchParams.delete(paramName);
} else {
newSearchParams.set(paramName, paramValue);
}
return newSearchParams;
}, { replace: true });
}, [setSearchParams]);

// Return the computed value and wrapped set state function
return [returnValue, returnSetter];
}

export const SearchContextProvider: React.FC<{
extraFilter?: Filter;
overrideSearchSortOrder?: SearchSortOption
Expand All @@ -96,7 +57,22 @@ export const SearchContextProvider: React.FC<{
}> = ({
overrideSearchSortOrder, skipBlockTypeFetch, skipUrlUpdate, ...props
}) => {
const [searchKeywords, setSearchKeywords] = React.useState('');
// Search parameters can be set via the query string
// E.g. q=draft+text
// TODO -- how to scrub search terms?
const keywordStateManager = React.useState('');
const keywordUrlStateManager = useStateWithUrlSearchParam<string>(
'',
'q',
(value: string) => value || '',
(value: string) => value || '',
);
const [searchKeywords, setSearchKeywords] = (
skipUrlUpdate
? keywordStateManager
: keywordUrlStateManager
);

const [blockTypesFilter, setBlockTypesFilter] = React.useState<string[]>([]);
const [problemTypesFilter, setProblemTypesFilter] = React.useState<string[]>([]);
const [tagsFilter, setTagsFilter] = React.useState<string[]>([]);
Expand Down

0 comments on commit 8fdbe9b

Please sign in to comment.