@@ -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 (