diff --git a/.gitignore b/.gitignore index f2d137d7134..b5388e2c1eb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ backend/tools app/electron/src/* docs/development/storybook/ .plugins +node_modules diff --git a/docs/development/frontend.md b/docs/development/frontend.md index dd84914a788..6902439b584 100644 --- a/docs/development/frontend.md +++ b/docs/development/frontend.md @@ -51,6 +51,15 @@ From within the [Headlamp](https://github.com/kinvolk/headlamp/) repo run: make storybook ``` +If you are adding new stories, please wrap your story components with the `TestContext` helper +component as it sets up the store, memory router, and other utilities that may be needed for +current or future stories: + +```jsx + + + +``` ## Accessibility (a11y) diff --git a/frontend/src/components/common/Resource/ResourceTable.tsx b/frontend/src/components/common/Resource/ResourceTable.tsx index 84cc6dd3b65..6f74b98075f 100644 --- a/frontend/src/components/common/Resource/ResourceTable.tsx +++ b/frontend/src/components/common/Resource/ResourceTable.tsx @@ -105,6 +105,7 @@ function Table(props: ResourceTableProps) { rowsPerPage={[15, 25, 50]} defaultSortingColumn={sortingColumn} filterFunction={useFilterFunc()} + reflectInURL {...otherProps} /> ); diff --git a/frontend/src/components/common/SimpleTable.stories.tsx b/frontend/src/components/common/SimpleTable.stories.tsx index 57a1ee03931..16d99b4a531 100644 --- a/frontend/src/components/common/SimpleTable.stories.tsx +++ b/frontend/src/components/common/SimpleTable.stories.tsx @@ -1,10 +1,12 @@ +import { Box, Typography } from '@material-ui/core'; import { Meta, Story } from '@storybook/react/types-6-0'; import { Provider } from 'react-redux'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, useLocation } from 'react-router-dom'; import { createStore } from 'redux'; import { KubeObjectInterface } from '../../lib/k8s/cluster'; import { useFilterFunc } from '../../lib/util'; import store from '../../redux/stores/store'; +import { TestContext, TestContextProps } from '../../test'; import SectionFilterHeader from './SectionFilterHeader'; import SimpleTable, { SimpleTableProps } from './SimpleTable'; @@ -14,10 +16,27 @@ export default { argTypes: {}, } as Meta; +function TestSimpleTable(props: SimpleTableProps) { + const location = useLocation(); + if (!!props.reflectInURL) { + return ( + + Test changing the page and rows per page. + + Current URL search: {`${location.search || ''}`} + + + + ); + } + + return ; +} + const Template: Story = args => ( - + ); @@ -131,6 +150,83 @@ Datum.args = { ], }; +const TemplateWithURLReflection: Story<{ + simpleTableProps: SimpleTableProps; + testContextProps: TestContextProps; +}> = args => { + const { testContextProps, simpleTableProps } = args; + return ( + + + + ); +}; + +export const ReflectInURL = TemplateWithURLReflection.bind({}); +const lotsOfData = (() => { + const data = []; + for (let i = 0; i < 50; i++) { + data.push({ + name: `Name ${i}`, + namespace: `Namespace ${i}`, + number: i, + }); + } + return data; +})(); +ReflectInURL.args = { + simpleTableProps: { + data: lotsOfData, + columns: [ + { + label: 'Name', + datum: 'name', + }, + { + label: 'Namespace', + datum: 'namespace', + }, + { + label: 'Number', + datum: 'number', + }, + ], + rowsPerPage: [5, 10, 15], + reflectInURL: true, + showPagination: !process.env.UNDER_TEST, // Disable for snapshots: The pagination uses useId so snapshots will fail. + }, + testContextProps: { + urlSearchParams: { p: '2' }, // 2nd page + }, +}; + +export const ReflectInURLWithPrefix = TemplateWithURLReflection.bind({}); +ReflectInURLWithPrefix.args = { + simpleTableProps: { + data: lotsOfData, + columns: [ + { + label: 'Name', + datum: 'name', + }, + { + label: 'Namespace', + datum: 'namespace', + }, + { + label: 'Number', + datum: 'creationDate', + }, + ], + rowsPerPage: [5, 10, 15], + reflectInURL: 'mySuperTable', + showPagination: !process.env.UNDER_TEST, // Disable for snapshots: The pagination uses useId so snapshots will fail. + }, + testContextProps: { + urlSearchParams: { p: '2' }, // 2nd page + }, +}; + // filter Function type SimpleTableWithFilterProps = SimpleTableProps & { matchCriteria?: string[] }; diff --git a/frontend/src/components/common/SimpleTable.tsx b/frontend/src/components/common/SimpleTable.tsx index c7a4e77275f..1ba71c02c77 100644 --- a/frontend/src/components/common/SimpleTable.tsx +++ b/frontend/src/components/common/SimpleTable.tsx @@ -11,6 +11,7 @@ import TableRow from '@material-ui/core/TableRow'; import React from 'react'; import { useTranslation } from 'react-i18next'; import helpers from '../../helpers'; +import { useURLState } from '../../lib/util'; import Empty from './EmptyContent'; import { ValueLabel } from './Label'; import Loader from './Loader'; @@ -76,6 +77,15 @@ export interface SimpleTableProps { errorMessage?: string | null; defaultSortingColumn?: number; noTableHeader?: boolean; + /** Whether to reflect the page/perPage properties in the URL. + * If assigned to a string, it will be the prefix for the page/perPage parameters. + * If true or '', it'll reflect the parameters without a prefix. + * By default, no parameters are reflected in the URL. */ + reflectInURL?: string | boolean; + /** The page number to show by default (by default it's the first page). */ + page?: number; + /** Whether to show the pagination component */ + showPagination?: boolean; } interface ColumnSortButtonProps { @@ -106,23 +116,60 @@ function ColumnSortButtons(props: ColumnSortButtonProps) { ); } +// Use a zero-indexed "useURLState" hook, so pages are shown in the URL as 1-indexed +// but internally are 0-indexed. +function usePageURLState( + key: string, + prefix: string, + initialPage: number +): ReturnType { + const [page, setPage] = useURLState(key, { defaultValue: initialPage + 1, prefix }); + const [zeroIndexPage, setZeroIndexPage] = React.useState(page - 1); + + React.useEffect(() => { + setZeroIndexPage((zeroIndexPage: number) => { + if (page - 1 !== zeroIndexPage) { + return page - 1; + } + + return zeroIndexPage; + }); + }, [page]); + + React.useEffect(() => { + setPage(zeroIndexPage + 1); + }, [zeroIndexPage]); + + return [zeroIndexPage, setZeroIndexPage]; +} + export default function SimpleTable(props: SimpleTableProps) { const { columns, data, filterFunction = null, emptyMessage = null, + page: initialPage = 0, + showPagination = true, errorMessage = null, defaultSortingColumn, noTableHeader = false, + reflectInURL, } = props; - const [page, setPage] = React.useState(0); + const shouldReflectInURL = reflectInURL !== undefined && reflectInURL !== false; + const prefix = reflectInURL === true ? '' : reflectInURL || ''; + const [page, setPage] = usePageURLState(shouldReflectInURL ? 'p' : '', prefix, initialPage); const [currentData, setCurrentData] = React.useState(data); const [displayData, setDisplayData] = React.useState(data); const rowsPerPageOptions = props.rowsPerPage || [15, 25, 50]; - const [rowsPerPage, setRowsPerPage] = React.useState( - helpers.getTablesRowsPerPage(rowsPerPageOptions[0]) + const defaultRowsPerPage = React.useMemo( + () => helpers.getTablesRowsPerPage(rowsPerPageOptions[0]), + [] ); + const [rowsPerPage, setRowsPerPage] = useURLState(shouldReflectInURL ? 'perPage' : '', { + defaultValue: defaultRowsPerPage, + prefix, + }); const classes = useTableStyle(); const [isIncreasingOrder, setIsIncreasingOrder] = React.useState( !defaultSortingColumn || defaultSortingColumn > 0 @@ -137,6 +184,18 @@ export default function SimpleTable(props: SimpleTableProps) { setPage(newPage); } + // Protect against invalid page values + React.useEffect(() => { + if (page < 0) { + setPage(0); + return; + } + + if (displayData && page * rowsPerPage > displayData.length) { + setPage(Math.floor(displayData.length / rowsPerPage)); + } + }, [page, displayData, rowsPerPage]); + function handleChangeRowsPerPage( event: React.ChangeEvent | React.ChangeEvent ) { @@ -149,9 +208,6 @@ export default function SimpleTable(props: SimpleTableProps) { React.useEffect( () => { if (currentData === data) { - if (page !== 0) { - setPage(0); - } return; } @@ -270,6 +326,7 @@ export default function SimpleTable(props: SimpleTableProps) { startIcon={} onClick={() => { setCurrentData(data); + setPage(0); }} > {t('frequent|Refresh')} @@ -334,7 +391,7 @@ export default function SimpleTable(props: SimpleTableProps) { )} - {filteredData.length > rowsPerPageOptions[0] && ( + {filteredData.length > rowsPerPageOptions[0] && showPagination && ( `; +exports[`Storyshots SimpleTable Reflect In URL 1`] = ` +
+
+

+ Test changing the page and rows per page. +

+

+ + Current URL search: + + + ?p=2 +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Name + + Namespace + + Number +
+ Name 5 + + Namespace 5 + + 5 +
+ Name 6 + + Namespace 6 + + 6 +
+ Name 7 + + Namespace 7 + + 7 +
+ Name 8 + + Namespace 8 + + 8 +
+ Name 9 + + Namespace 9 + + 9 +
+
+
+`; + +exports[`Storyshots SimpleTable Reflect In URL With Prefix 1`] = ` +
+
+

+ Test changing the page and rows per page. +

+

+ + Current URL search: + + + ?p=2 +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Name + + Namespace + + Number +
+ Name 0 + + Namespace 0 + +
+ Name 1 + + Namespace 1 + +
+ Name 2 + + Namespace 2 + +
+ Name 3 + + Namespace 3 + +
+ Name 4 + + Namespace 4 + +
+
+
+`; + exports[`Storyshots SimpleTable UID Search 1`] = `
@@ -157,6 +158,7 @@ export default function CustomResourceDefinitionDetails() { getter: version => version.storage.toString(), }, ]} + reflectInURL="versions" /> @@ -177,6 +179,7 @@ export default function CustomResourceDefinitionDetails() { }, 'age', ]} + reflectInURL="objects" /> diff --git a/frontend/src/components/daemonset/Details.tsx b/frontend/src/components/daemonset/Details.tsx index 51bb362a612..e17b3407ce1 100644 --- a/frontend/src/components/daemonset/Details.tsx +++ b/frontend/src/components/daemonset/Details.tsx @@ -60,6 +60,7 @@ function TolerationsSection(props: TolerationsSection) { sort: true, }, ]} + reflectInURL="tolerations" /> ); diff --git a/frontend/src/components/endpoints/Details.tsx b/frontend/src/components/endpoints/Details.tsx index 12b2870a83a..3ec971683fc 100644 --- a/frontend/src/components/endpoints/Details.tsx +++ b/frontend/src/components/endpoints/Details.tsx @@ -70,6 +70,7 @@ export default function EndpointDetails() { }, }, ]} + reflectInURL="addresses" /> )) diff --git a/frontend/src/components/endpoints/EndpointDetails.stories.tsx b/frontend/src/components/endpoints/EndpointDetails.stories.tsx index c1d8638b258..d64da0f40f3 100644 --- a/frontend/src/components/endpoints/EndpointDetails.stories.tsx +++ b/frontend/src/components/endpoints/EndpointDetails.stories.tsx @@ -89,7 +89,11 @@ const Template: Story = (args: MockerStory) => { Endpoints.useList = args.useList; } - return ; + return ( + + + + ); }; export const Default = Template.bind({}); diff --git a/frontend/src/components/endpoints/__snapshots__/EndpointDetails.stories.storyshot b/frontend/src/components/endpoints/__snapshots__/EndpointDetails.stories.storyshot index 6264c46ffe8..9414ed54bf0 100644 --- a/frontend/src/components/endpoints/__snapshots__/EndpointDetails.stories.storyshot +++ b/frontend/src/components/endpoints/__snapshots__/EndpointDetails.stories.storyshot @@ -111,33 +111,7 @@ exports[`Storyshots endpoints/EndpointsDetailsView Default 1`] = ` - - my-endpoint - - - - - - Namespace - - - - my-namespace - - + /> )} diff --git a/frontend/src/components/pod/Details.tsx b/frontend/src/components/pod/Details.tsx index 9836613a5c7..5a9737ed4c7 100644 --- a/frontend/src/components/pod/Details.tsx +++ b/frontend/src/components/pod/Details.tsx @@ -271,6 +271,7 @@ export function VolumeDetails(props: VolumeDetailsProps) { }, ]} data={volumes} + reflectInURL="volumes" /> ); diff --git a/frontend/src/components/pod/List.tsx b/frontend/src/components/pod/List.tsx index 1c7134a0485..db186374716 100644 --- a/frontend/src/components/pod/List.tsx +++ b/frontend/src/components/pod/List.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { ApiError } from '../../lib/k8s/apiProxy'; import Pod from '../../lib/k8s/pod'; import { timeAgo } from '../../lib/util'; -import { LightTooltip, SectionFilterHeader } from '../common'; +import { LightTooltip, SectionFilterHeader, SimpleTableProps } from '../common'; import { StatusLabel, StatusLabelProps } from '../common/Label'; import ResourceTable, { ResourceTableProps } from '../common/Resource/ResourceTable'; import { SectionBox } from '../common/SectionBox'; @@ -52,10 +52,11 @@ export interface PodListProps { pods: Pod[] | null; error: ApiError | null; hideColumns?: ('namespace' | 'restarts')[]; + reflectTableInURL?: SimpleTableProps['reflectInURL']; } export function PodListRenderer(props: PodListProps) { - const { pods, error, hideColumns = [] } = props; + const { pods, error, hideColumns = [], reflectTableInURL = 'pods' } = props; const { t } = useTranslation('glossary'); function getDataCols() { @@ -110,6 +111,7 @@ export function PodListRenderer(props: PodListProps) { errorMessage={Pod.getErrorMessage(error)} columns={getDataCols()} data={pods} + reflectInURL={reflectTableInURL} /> ); @@ -118,5 +120,5 @@ export function PodListRenderer(props: PodListProps) { export default function PodList() { const [pods, error] = Pod.useList(); - return ; + return ; } diff --git a/frontend/src/components/role/BindingDetails.tsx b/frontend/src/components/role/BindingDetails.tsx index 13f15f1bf89..48efa0284ce 100644 --- a/frontend/src/components/role/BindingDetails.tsx +++ b/frontend/src/components/role/BindingDetails.tsx @@ -67,6 +67,7 @@ export default function RoleBindingDetails() { getter: item => item.namespace, }, ]} + reflectInURL="bindingInfo" /> ) diff --git a/frontend/src/components/role/Details.tsx b/frontend/src/components/role/Details.tsx index c555adf9780..30d1f88e8c5 100644 --- a/frontend/src/components/role/Details.tsx +++ b/frontend/src/components/role/Details.tsx @@ -42,6 +42,7 @@ export default function RoleDetails() { }, ]} data={item.rules} + reflectInURL="rules" /> ) diff --git a/frontend/src/components/service/Details.tsx b/frontend/src/components/service/Details.tsx index 2c45fff0d4d..d25adcee560 100644 --- a/frontend/src/components/service/Details.tsx +++ b/frontend/src/components/service/Details.tsx @@ -74,6 +74,7 @@ export default function ServiceDetails() { ), }, ]} + reflectInURL="ports" /> @@ -93,6 +94,7 @@ export default function ServiceDetails() { cellProps: { style: { width: '40%', maxWidth: '40%' } }, }, ]} + reflectInURL="endpoints" /> )} diff --git a/frontend/src/lib/util.ts b/frontend/src/lib/util.ts index 38acf9c9638..0fa8690ebea 100644 --- a/frontend/src/lib/util.ts +++ b/frontend/src/lib/util.ts @@ -1,7 +1,7 @@ import humanizeDuration from 'humanize-duration'; import { JSONPath } from 'jsonpath-plus'; import React from 'react'; -import { matchPath } from 'react-router'; +import { matchPath, useHistory } from 'react-router'; import helpers from '../helpers'; import { useTypedSelector } from '../redux/reducers/reducers'; import { ApiError } from './k8s/apiProxy'; @@ -234,6 +234,119 @@ export function useErrorState(dependentSetter?: (...args: any) => void) { return [error, setError as any]; } +type URLStateParams = { + /** The defaultValue for the URL state. */ + defaultValue: T; + /** Whether to hide the parameter when the value is the default one (true by default). */ + hideDefault?: boolean; + /** The prefix of the URL key to use for this state (a prefix 'my' with a key name 'key' will be used in the URL as 'my.key'). */ + prefix?: string; +}; +export function useURLState( + key: string, + defaultValue: number +): [number, React.Dispatch>]; +export function useURLState( + key: string, + valueOrParams: number | URLStateParams +): [number, React.Dispatch>]; +/** + * A hook to manage a state variable that is also stored in the URL. + * + * @param key The name of the key in the URL. If empty, then the hook behaves like useState. + * @param paramsOrDefault The default value of the state variable, or the params object. + * + */ +export function useURLState( + key: string, + paramsOrDefault: T | URLStateParams +): [T, React.Dispatch>] { + const params: URLStateParams = + typeof paramsOrDefault === 'object' ? paramsOrDefault : { defaultValue: paramsOrDefault }; + const { defaultValue, hideDefault = true, prefix = '' } = params; + const history = useHistory(); + // Don't even use the prefix if the key is empty + const fullKey = !key ? '' : !!prefix ? prefix + '.' + key : key; + + function getURLValue() { + // An empty key means that we don't want to use the state from the URL. + if (fullKey === '') { + return null; + } + + const urlParams = new URLSearchParams(history.location.search); + const urlValue = urlParams.get(fullKey); + if (urlValue === null) { + return null; + } + let newValue: string | number = urlValue; + if (typeof defaultValue === 'number') { + newValue = Number(urlValue); + if (newValue === NaN) { + return null; + } + } + + return newValue; + } + + const initialValue = React.useMemo(() => { + const newValue = getURLValue(); + if (newValue === null) { + return defaultValue; + } + return newValue; + }, []); + const [value, setValue] = React.useState(initialValue as T); + + React.useEffect( + () => { + const newValue = getURLValue(); + if (newValue === null) { + if (defaultValue !== undefined && defaultValue !== value) { + setValue(defaultValue); + } + } else if (newValue !== value) { + setValue(newValue as T); + } + }, + // eslint-disable-next-line + [history] + ); + + React.useEffect(() => { + // An empty key means that we don't want to use the state from the URL. + if (fullKey === '') { + return; + } + + const urlCurrentValue = getURLValue(); + + if (urlCurrentValue === value) { + return; + } + + const urlParams = new URLSearchParams(history.location.search); + let shouldUpdateURL = false; + + if ((value === null || value === defaultValue) && hideDefault) { + urlParams.delete(fullKey); + shouldUpdateURL = true; + } else if (value !== undefined) { + const urlValue = value as NonNullable; + + urlParams.set(fullKey, urlValue.toString()); + shouldUpdateURL = true; + } + + if (shouldUpdateURL) { + history.replace({ ...location, search: urlParams.toString() }); + } + }, [value]); + + return [value, setValue] as [T, React.Dispatch>]; +} + // Make units available from here export * as auth from './auth'; export * as units from './units'; diff --git a/frontend/src/test/index.tsx b/frontend/src/test/index.tsx index e70aa611be8..45871df9d65 100644 --- a/frontend/src/test/index.tsx +++ b/frontend/src/test/index.tsx @@ -7,10 +7,13 @@ import defaultStore from '../redux/stores/store'; export type TestContextProps = PropsWithChildren<{ store?: ReturnType; routerMap?: Record; + urlSearchParams?: { + [key: string]: string; + }; }>; export function TestContext(props: TestContextProps) { - const { store, routerMap, children } = props; + const { store, routerMap, urlSearchParams, children } = props; let url = ''; let routePath = ''; @@ -20,6 +23,10 @@ export function TestContext(props: TestContextProps) { url += '/' + value; } + if (!!urlSearchParams) { + url += '?' + new URLSearchParams(urlSearchParams).toString(); + } + return (