Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Tech Stories
---

Migrate Object Storage to Tanstack Router ([#11924](https://github.com/linode/manager/pull/11924))
1 change: 1 addition & 0 deletions packages/manager/.eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ module.exports = {
'src/features/Images/**/*',
'src/features/Longview/**/*',
'src/features/NodeBalancers/**/*',
'src/features/ObjectStorage/**/*',
'src/features/PlacementGroups/**/*',
'src/features/StackScripts/**/*',
'src/features/Volumes/**/*',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,7 @@ describe('object storage end-to-end tests', () => {
cy.findByLabelText('Content is loading').should('not.exist');
});

ui.entityHeader.find().within(() => {
ui.button.findByTitle('Create Bucket').should('be.visible').click();
});
ui.button.findByTitle('Create Bucket').should('be.visible').click();

ui.drawer
.findByTitle('Create Bucket')
Expand Down Expand Up @@ -170,31 +168,31 @@ describe('object storage end-to-end tests', () => {
interceptUpdateBucketAccess(bucketLabel, bucketCluster).as(
'updateBucketAccess'
);
});

// Navigate to new bucket page, upload and delete an object.
cy.visitWithLogin(bucketAccessPage);
// Navigate to new bucket page, upload and delete an object.
cy.visitWithLogin(bucketAccessPage);

cy.wait('@getBucketAccess');
cy.wait('@getBucketAccess');

// Make object public, confirm it can be accessed.
cy.findByLabelText('Access Control List (ACL)')
.should('be.visible')
.should('not.have.value', 'Loading access...')
.should('have.value', 'Private')
.click();
cy.focused().type('Public Read');
// Make object public, confirm it can be accessed.
cy.findByLabelText('Access Control List (ACL)')
.should('be.visible')
.should('not.have.value', 'Loading access...')
.should('have.value', 'Private')
.click();
cy.focused().type('Public Read');

ui.autocompletePopper
.findByTitle('Public Read')
.should('be.visible')
.click();
ui.autocompletePopper
.findByTitle('Public Read')
.should('be.visible')
.click();

ui.button.findByTitle('Save').should('be.visible').click();
ui.button.findByTitle('Save').should('be.visible').click();

// TODO Confirm that outgoing API request contains expected values.
cy.wait('@updateBucketAccess');
// TODO Confirm that outgoing API request contains expected values.
cy.wait('@updateBucketAccess');

cy.findByText('Bucket access updated successfully.');
cy.findByText('Bucket access updated successfully.');
});
Comment thread
abailly-akamai marked this conversation as resolved.
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,7 @@ describe('Object Storage Multicluster Bucket create', () => {
cy.visitWithLogin('/object-storage');
cy.wait(['@getRegions', '@getBuckets']);

ui.entityHeader.find().within(() => {
ui.button.findByTitle('Create Bucket').should('be.visible').click();
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes 0 sense to me this was passing before... there is no 'Create Bucket' CTA within the entity header πŸ€”

ui.button.findByTitle('Create Bucket').should('be.visible').click();

ui.drawer
.findByTitle('Create Bucket')
Expand Down
5 changes: 0 additions & 5 deletions packages/manager/src/MainContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,6 @@ const Kubernetes = React.lazy(() =>
default: module.Kubernetes,
}))
);
const ObjectStorage = React.lazy(() => import('src/features/ObjectStorage'));
const Profile = React.lazy(() =>
import('src/features/Profile/Profile').then((module) => ({
default: module.Profile,
Expand Down Expand Up @@ -365,10 +364,6 @@ export const MainContent = () => {
<Switch>
<Route component={LinodesRoutes} path="/linodes" />
<Route component={Managed} path="/managed" />
<Route
component={ObjectStorage}
path="/object-storage"
/>
<Route component={Kubernetes} path="/kubernetes" />
{isIAMEnabled && (
<Route component={IAM} path="/iam" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const TanStackTabLinkList = ({ noLink, tabs }: TabLinkListProps) => {
<Tab
// @ts-expect-error - Tab accepts 'as' prop at runtime but it's not in the types
as={noLink ? undefined : TanstackLink}
data-testid={tab.title}
key={`tab-${_index}`}
preload={noLink ? undefined : 'intent'}
to={noLink ? undefined : tab.to}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
import { getObjectList, getObjectURL } from '@linode/api-v4/lib/object-storage';
import { useAccount } from '@linode/queries';
import { ActionsPanel, Box } from '@linode/ui';
import {
getQueryParamFromQueryString,
isFeatureEnabledV2,
truncateMiddle,
} from '@linode/utilities';
import { isFeatureEnabledV2, truncateMiddle } from '@linode/utilities';
import { useQueryClient } from '@tanstack/react-query';
import { useParams, useSearch } from '@tanstack/react-router';
import produce from 'immer';
import { useSnackbar } from 'notistack';
import * as React from 'react';
import { useHistory, useLocation, useRouteMatch } from 'react-router-dom';
import { Waypoint } from 'react-waypoint';
import { debounce } from 'throttle-debounce';

Expand Down Expand Up @@ -55,19 +51,14 @@ import { ObjectDetailsDrawer } from './ObjectDetailsDrawer';
import ObjectTableContent from './ObjectTableContent';

import type {
ObjectStorageClusterID,
ObjectStorageEndpointTypes,
ObjectStorageObject,
ObjectStorageObjectList,
} from '@linode/api-v4';
import type { InfiniteData } from '@tanstack/react-query';

interface MatchParams {
bucketName: string;
clusterId: ObjectStorageClusterID;
}
interface Props {
endpointType: ObjectStorageEndpointTypes;
endpointType: ObjectStorageEndpointTypes | undefined;
}

export const BucketDetail = (props: Props) => {
Expand All @@ -76,15 +67,13 @@ export const BucketDetail = (props: Props) => {
* @note If `Object Storage Access Key Regions` is enabled, clusterId will actually contain
* the bucket's region id
*/
const match = useRouteMatch<MatchParams>(
'/object-storage/buckets/:clusterId/:bucketName'
);
const location = useLocation();
const history = useHistory();
const { enqueueSnackbar } = useSnackbar();
const bucketName = match?.params.bucketName || '';
const clusterId = match?.params.clusterId || '';
const prefix = getQueryParamFromQueryString(location.search, 'prefix');
const { bucketName, clusterId } = useParams({
from: '/object-storage/buckets/$clusterId/$bucketName',
});
const { prefix = '' } = useSearch({
from: '/object-storage/buckets/$clusterId/$bucketName',
});
const queryClient = useQueryClient();

const flags = useFlags();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { getQueryParamFromQueryString } from '@linode/utilities';
import { useSearch } from '@tanstack/react-router';
import * as React from 'react';
import { useHistory, useLocation } from 'react-router-dom';

import { BucketRateLimitTable } from '../BucketLanding/BucketRateLimitTable';
import { BucketBreadcrumb } from './BucketBreadcrumb';
Expand All @@ -19,10 +18,9 @@ interface Props {
export const BucketProperties = React.memo((props: Props) => {
const { bucket } = props;
const { endpoint_type, hostname, label } = bucket;

const location = useLocation();
const history = useHistory();
const prefix = getQueryParamFromQueryString(location.search, 'prefix');
const { prefix = '' } = useSearch({
from: '/object-storage/buckets/$clusterId/$bucketName',
});

return (
<>
Expand Down
70 changes: 20 additions & 50 deletions packages/manager/src/features/ObjectStorage/BucketDetail/index.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,22 @@
import { useAccount } from '@linode/queries';
import { isFeatureEnabledV2 } from '@linode/utilities';
import { createLazyRoute } from '@tanstack/react-router';
import { useParams } from '@tanstack/react-router';
import * as React from 'react';
import { matchPath } from 'react-router-dom';

import { LandingHeader } from 'src/components/LandingHeader';
import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner';
import { SuspenseLoader } from 'src/components/SuspenseLoader';
import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel';
import { TabLinkList } from 'src/components/Tabs/TabLinkList';
import { TabPanels } from 'src/components/Tabs/TabPanels';
import { Tabs } from 'src/components/Tabs/Tabs';
import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList';
import { useFlags } from 'src/hooks/useFlags';
import { useTabs } from 'src/hooks/useTabs';
import { useObjectStorageBuckets } from 'src/queries/object-storage/queries';

import { BucketAccess } from './BucketAccess';

import type { ObjectStorageClusterID } from '@linode/api-v4/lib/object-storage';
import type { ComponentType, LazyExoticComponent } from 'react';
import type { RouteComponentProps } from 'react-router-dom';

const ObjectList: LazyExoticComponent<ComponentType<any>> = React.lazy(() =>
const ObjectList = React.lazy(() =>
import('./BucketDetail').then((module) => ({ default: module.BucketDetail }))
);
const BucketSSL = React.lazy(() =>
Expand All @@ -29,14 +25,10 @@ const BucketSSL = React.lazy(() =>
}))
);

interface MatchProps {
bucketName: string;
clusterId: ObjectStorageClusterID;
}

type Props = RouteComponentProps<MatchProps>;

export const BucketDetailLanding = React.memo((props: Props) => {
export const BucketDetailLanding = React.memo(() => {
const { bucketName, clusterId } = useParams({
from: '/object-storage/buckets/$clusterId/$bucketName',
});
const { data: account } = useAccount();
const flags = useFlags();

Expand All @@ -50,44 +42,28 @@ export const BucketDetailLanding = React.memo((props: Props) => {
isObjectStorageGen2Enabled
);

const matches = (p: string) => {
return Boolean(matchPath(p, { path: props.location.pathname }));
};
const { bucketName, clusterId } = props.match.params;

const bucket = bucketsData?.buckets.find(({ label }) => label === bucketName);

const { endpoint_type } = bucket ?? {};

const isGen2Endpoint = endpoint_type === 'E2' || endpoint_type === 'E3';

const tabs = [
const { handleTabChange, tabIndex, tabs } = useTabs([
{
routeName: `${props.match.url}/objects`,
title: 'Objects',
to: `/object-storage/buckets/$clusterId/$bucketName/objects`,
Comment thread
abailly-akamai marked this conversation as resolved.
},
{
routeName: `${props.match.url}/access`,
title: 'Access',
to: `/object-storage/buckets/$clusterId/$bucketName/access`,
},
...(!isGen2Endpoint
? [
{
routeName: `${props.match.url}/ssl`,
title: 'SSL/TLS',
},
]
: []),
];

const [index, setIndex] = React.useState(
tabs.findIndex((tab) => matches(tab.routeName)) || 0
);

const handleTabChange = (index: number) => {
setIndex(index);
props.history.push(tabs[index].routeName);
};
{
hide: isGen2Endpoint,
title: 'SSL/TLS',
to: `/object-storage/buckets/$clusterId/$bucketName/ssl`,
},
]);

return (
<>
Expand All @@ -108,13 +84,13 @@ export const BucketDetailLanding = React.memo((props: Props) => {
docsLink="https://www.linode.com/docs/platform/object-storage/"
/>

<Tabs index={index} onChange={handleTabChange}>
<TabLinkList tabs={tabs} />
<Tabs index={tabIndex} onChange={handleTabChange}>
<TanStackTabLinkList tabs={tabs} />

<React.Suspense fallback={<SuspenseLoader />}>
<TabPanels>
<SafeTabPanel index={0}>
<ObjectList {...props} endpointType={endpoint_type} />
<ObjectList endpointType={endpoint_type} />
</SafeTabPanel>
<SafeTabPanel index={1}>
<BucketAccess
Expand All @@ -133,10 +109,4 @@ export const BucketDetailLanding = React.memo((props: Props) => {
);
});

export const bucketDetailLandingLazyRoute = createLazyRoute(
'/object-storage/buckets/$clusterId/$bucketName'
)({
component: BucketDetailLanding,
});

export default BucketDetailLanding;
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,37 @@ import { renderWithTheme } from 'src/utilities/testHelpers';

import { BucketLanding } from './BucketLanding';

const queryMocks = vi.hoisted(() => ({
useNavigate: vi.fn(() => vi.fn()),
useOrderV2: vi.fn().mockReturnValue({}),
useSearch: vi.fn(),
}));

vi.mock('@tanstack/react-router', async () => {
const actual = await vi.importActual('@tanstack/react-router');
return {
...actual,
useNavigate: queryMocks.useNavigate,
useSearch: queryMocks.useSearch,
};
});

vi.mock('src/hooks/useOrderV2', async () => {
const actual = await vi.importActual('src/hooks/useOrderV2');
return {
...actual,
useOrderV2: queryMocks.useOrderV2,
};
});

describe('ObjectStorageLanding', () => {
beforeAll(() => server.listen());
beforeAll(() => {
server.listen();
queryMocks.useSearch.mockReturnValue({
order: 'asc',
orderBy: 'label',
});
});
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Expand Down Expand Up @@ -123,6 +152,11 @@ describe('ObjectStorageLanding', () => {

it('renders rows for each Bucket', async () => {
const buckets = objectStorageBucketFactory.buildList(2);
queryMocks.useOrderV2.mockReturnValue({
order: 'asc',
orderBy: 'label',
sortedData: buckets,
});

// Mock Clusters
server.use(
Expand Down
Loading