diff --git a/packages/manager/.changeset/pr-9724-tech-stories-1695847434977.md b/packages/manager/.changeset/pr-9724-tech-stories-1695847434977.md
new file mode 100644
index 00000000000..cc56f43051e
--- /dev/null
+++ b/packages/manager/.changeset/pr-9724-tech-stories-1695847434977.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Tech Stories
+---
+
+Improve loading patterns on Marketplace page ([#9724](https://github.com/linode/manager/pull/9724))
diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LoadingAppPanelSection.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LoadingAppPanelSection.tsx
new file mode 100644
index 00000000000..5f6749c4a8e
--- /dev/null
+++ b/packages/manager/src/features/Linodes/LinodesCreate/LoadingAppPanelSection.tsx
@@ -0,0 +1,51 @@
+import Grid from '@mui/material/Unstable_Grid2';
+import { styled } from '@mui/material/styles';
+import { useTheme } from '@mui/material/styles';
+import useMediaQuery from '@mui/material/useMediaQuery';
+import * as React from 'react';
+
+import { Divider } from 'src/components/Divider';
+import { Skeleton } from 'src/components/Skeleton';
+import { Typography } from 'src/components/Typography';
+
+interface Props {
+ desktopCount: number;
+ heading: string;
+ mobileCount: number;
+}
+
+export const LoadingAppPanelSection = (props: Props) => {
+ const { desktopCount, heading, mobileCount } = props;
+ const theme = useTheme();
+ const matchesSmDown = useMediaQuery(theme.breakpoints.down('md'));
+ const count = matchesSmDown ? mobileCount : desktopCount;
+
+ return (
+ <>
+ {heading}
+
+
+ {Array(count)
+ .fill(0)
+ .map((_, idx) => (
+
+
+
+ ))}
+
+ >
+ );
+};
+
+const AppPanelGrid = styled(Grid)(({ theme }) => ({
+ marginBottom: theme.spacing(),
+ marginTop: theme.spacing(2),
+ padding: `${theme.spacing(1)} 0`,
+}));
+
+const StyledSkeleton = styled(Skeleton, {
+ label: 'StyledSkeleton',
+})(({ theme }) => ({
+ height: 60,
+ transform: 'none',
+}));
diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectAppPanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectAppPanel.tsx
index 9a4b8b5ed4e..bdc0df555da 100644
--- a/packages/manager/src/features/Linodes/LinodesCreate/SelectAppPanel.tsx
+++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectAppPanel.tsx
@@ -4,15 +4,19 @@ import * as React from 'react';
import { compose } from 'recompose';
import { ErrorState } from 'src/components/ErrorState/ErrorState';
-import { LandingLoading } from 'src/components/LandingLoading/LandingLoading';
import { Notice } from 'src/components/Notice/Notice';
import { Paper } from 'src/components/Paper';
import { AppPanelSection } from 'src/features/Linodes/LinodesCreate/AppPanelSection';
import { getQueryParamFromQueryString } from 'src/utilities/queryParams';
+import { LoadingAppPanelSection } from './LoadingAppPanelSection';
import { Panel } from './Panel';
import { AppsData } from './types';
+const NEW_APPS_HEADER = 'New apps';
+const POPULAR_APPS_HEADER = 'Popular apps';
+const ALL_APPS_HEADER = 'All apps';
+
interface Props extends AppsData {
disabled: boolean;
error?: string;
@@ -69,11 +73,23 @@ class SelectAppPanel extends React.PureComponent {
if (appInstancesLoading || !appInstances) {
return (
-
-
-
-
-
+
+
+
+
+
);
}
@@ -105,7 +121,7 @@ class SelectAppPanel extends React.PureComponent {
apps={newApps}
disabled={disabled}
handleClick={handleClick}
- heading="New apps"
+ heading={NEW_APPS_HEADER}
openDrawer={openDrawer}
selectedStackScriptID={selectedStackScriptID}
/>
@@ -115,7 +131,7 @@ class SelectAppPanel extends React.PureComponent {
apps={popularApps}
disabled={disabled}
handleClick={handleClick}
- heading="Popular apps"
+ heading={POPULAR_APPS_HEADER}
openDrawer={openDrawer}
selectedStackScriptID={selectedStackScriptID}
/>
@@ -124,7 +140,7 @@ class SelectAppPanel extends React.PureComponent {
apps={allApps}
disabled={disabled}
handleClick={handleClick}
- heading={isFilteringOrSearching ? '' : 'All apps'}
+ heading={isFilteringOrSearching ? '' : ALL_APPS_HEADER}
openDrawer={openDrawer}
searchValue={searchValue}
selectedStackScriptID={selectedStackScriptID}
@@ -191,10 +207,4 @@ const StyledPaper = styled(Paper, { label: 'StyledPaper' })(({ theme }) => ({
...commonStyling(theme),
}));
-const StyledLoadingSpan = styled('span', { label: 'StyledLoadingSpan' })({
- '& >div:first-of-type': {
- height: 450,
- },
-});
-
export default compose(React.memo)(SelectAppPanel);
diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx
index 4bf6bdb3568..9a72df3f8fe 100644
--- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx
+++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx
@@ -1,17 +1,18 @@
import { Image } from '@linode/api-v4/lib/images';
import { StackScript, UserDefinedField } from '@linode/api-v4/lib/stackscripts';
-import { styled } from '@mui/material/styles';
+import Close from '@mui/icons-material/Close';
+import Grid from '@mui/material/Unstable_Grid2';
import compact from 'lodash/compact';
import curry from 'lodash/curry';
import { assocPath } from 'ramda';
import * as React from 'react';
-import { Box } from 'src/components/Box';
import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField';
import Select, { Item } from 'src/components/EnhancedSelect';
+import { IconButton } from 'src/components/IconButton';
import { ImageSelect } from 'src/components/ImageSelect/ImageSelect';
-import { Typography } from 'src/components/Typography';
import { Paper } from 'src/components/Paper';
+import { Typography } from 'src/components/Typography';
import { APP_ROOT } from 'src/constants';
import { ImageEmptyState } from 'src/features/Linodes/LinodesCreate/TabbedContent/ImageEmptyState';
import { AppDetailDrawer } from 'src/features/OneClickApps';
@@ -22,6 +23,7 @@ import {
import UserDefinedFieldsPanel from 'src/features/StackScripts/UserDefinedFieldsPanel';
import { sendMarketplaceSearchEvent } from 'src/utilities/analytics';
import getAPIErrorsFor from 'src/utilities/getAPIErrorFor';
+import { initializePercentageCounter } from 'src/utilities/percentageCounter';
import SelectAppPanel from '../SelectAppPanel';
import {
@@ -30,8 +32,8 @@ import {
StackScriptFormStateHandlers,
WithTypesRegionsAndImages,
} from '../types';
-import { filterUDFErrors } from './formUtilities';
import { StyledGrid } from './CommonTabbedContent.styles';
+import { filterUDFErrors } from './formUtilities';
const appCategories = [
'Control Panels',
@@ -80,6 +82,7 @@ interface State {
filteredApps: CombinedProps['appInstances'];
isFiltering: boolean;
isSearching: boolean;
+ percentageCounter: number;
query: string;
selectedScriptForDrawer: string;
}
@@ -136,6 +139,14 @@ const renderLogo = (selectedStackScriptLabel?: string, logoUrl?: string) => {
const curriedHandleSelectStackScript = curry(handleSelectStackScript);
export class FromAppsContent extends React.Component {
+ componentDidMount() {
+ this.percentageCounter.startAnimation(this.updatePercentage);
+ }
+
+ componentWillUnmount() {
+ this.percentageCounter.stopAnimation();
+ }
+
render() {
const {
appInstances,
@@ -168,6 +179,13 @@ export class FromAppsContent extends React.Component {
sendMarketplaceSearchEvent('Search Field');
};
+ const handleClearSearch = () => {
+ this.setState({
+ isSearching: false,
+ query: '',
+ });
+ };
+
const logoUrl = appInstances?.find(
(app) => app.id === selectedStackScriptID
)?.logo_url;
@@ -179,6 +197,7 @@ export class FromAppsContent extends React.Component {
filteredApps,
isFiltering,
isSearching,
+ percentageCounter,
query,
} = this.state;
@@ -189,15 +208,48 @@ export class FromAppsContent extends React.Component {
Select an App
-
-
+
+
+
+
+ ),
+ }
+ : undefined
+ }
placeholder={
- appInstancesLoading ? 'Loading...' : 'Search for app name'
+ appInstancesLoading
+ ? `Loading... ${percentageCounter.toFixed(0)}%`
+ : 'Search for app name'
}
sx={
appInstancesLoading
? {
+ '& .MuiInput-input::placeholder': {
+ opacity: 1,
+ },
'& input': {
cursor: 'not-allowed',
},
@@ -207,6 +259,7 @@ export class FromAppsContent extends React.Component {
}
: null
}
+ debounceTime={250}
disabled={appInstancesLoading}
fullWidth
hideLabel
@@ -215,8 +268,8 @@ export class FromAppsContent extends React.Component {
onSearch={this.onSearch}
value={query}
/>
-
-
+
+
-
+
+
{
});
};
+ // Based on average API response time
+ percentageCounter = initializePercentageCounter(8000);
+
state: State = {
categoryFilter: null,
detailDrawerOpen: false,
filteredApps: [],
isFiltering: false,
isSearching: false,
+ percentageCounter: 0,
query: '',
selectedScriptForDrawer: '',
};
-}
-const StyledSearchFilterBox = styled(Box, { label: 'StyledSearchFilterBox' })(
- ({ theme }) => ({
- '& > h2': {
- width: '100%',
- },
- display: 'flex',
- gap: theme.spacing(),
- justifyContent: 'space-between',
- marginTop: theme.spacing(),
- })
-);
-
-const StyledFilterBox = styled(Box, { label: 'StyledFilterBox' })({
- flexGrow: 1.5,
-});
-
-const StyledSearchBox = styled(Box, { label: 'StyledSearchBox' })({
- '& .input': {
- maxWidth: 'none',
- },
- flexGrow: 10,
-});
+ updatePercentage = (newPercentage: number) => {
+ this.setState({ percentageCounter: newPercentage });
+ };
+}
diff --git a/packages/manager/src/queries/stackscripts.ts b/packages/manager/src/queries/stackscripts.ts
index d719e80e645..4ad5a08e4c6 100644
--- a/packages/manager/src/queries/stackscripts.ts
+++ b/packages/manager/src/queries/stackscripts.ts
@@ -11,7 +11,7 @@ export const queryKey = 'stackscripts';
export const useStackScriptsOCA = (enabled: boolean, params: Params = {}) => {
return useQuery(
- [`${queryKey}-oca-all`, params],
+ `${queryKey}-oca-all`,
() => getAllOCAsRequest(params),
{
enabled,
diff --git a/packages/manager/src/utilities/percentageCounter.test.ts b/packages/manager/src/utilities/percentageCounter.test.ts
new file mode 100644
index 00000000000..3484e2042b7
--- /dev/null
+++ b/packages/manager/src/utilities/percentageCounter.test.ts
@@ -0,0 +1,85 @@
+import { initializePercentageCounter } from './percentageCounter';
+
+describe('initializePercentageCounter', () => {
+ let animationFrameSpy: jest.SpyInstance;
+ let cancelAnimationFrameSpy: jest.SpyInstance;
+ let requestAnimationFrameSpy: jest.SpyInstance<
+ number,
+ [FrameRequestCallback]
+ >;
+
+ beforeEach(() => {
+ animationFrameSpy = jest.spyOn(window, 'requestAnimationFrame');
+ cancelAnimationFrameSpy = jest.spyOn(window, 'cancelAnimationFrame');
+ requestAnimationFrameSpy = jest.spyOn(window, 'requestAnimationFrame');
+ });
+
+ afterEach(() => {
+ animationFrameSpy.mockRestore();
+ cancelAnimationFrameSpy.mockRestore();
+ requestAnimationFrameSpy.mockRestore();
+ });
+
+ it('should start and stop animation correctly', () => {
+ const duration = 2000;
+ const onUpdateMock = jest.fn();
+
+ const { startAnimation, stopAnimation } = initializePercentageCounter(
+ duration
+ );
+
+ // Start animation
+ startAnimation(onUpdateMock);
+
+ // Ensure requestAnimationFrame was called
+ expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1);
+
+ // Manually trigger the animation frame callback
+ const frameCallback = requestAnimationFrameSpy.mock.calls[0][0];
+ frameCallback(performance.now()); // Simulate a frame update
+
+ // Check if onUpdate was called with an updated percentage
+ expect(onUpdateMock).toHaveBeenCalledWith(expect.any(Number));
+
+ // Stop animation
+ stopAnimation();
+
+ // Ensure cancelAnimationFrame was called
+ expect(cancelAnimationFrameSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not exceed 99% during animation', () => {
+ const duration = 10000; // 10 seconds
+ const onUpdateMock = jest.fn();
+
+ const { startAnimation, stopAnimation } = initializePercentageCounter(
+ duration
+ );
+
+ // Start animation
+ startAnimation(onUpdateMock);
+
+ // Manually trigger the animation frame callback for the entire duration
+ const frameCallback = requestAnimationFrameSpy.mock.calls[0][0];
+ for (let time = 0; time < duration; time += 1000) {
+ frameCallback(performance.now() + time); // Simulate frame updates
+ }
+
+ // Ensure percentage never exceeded 99
+ expect(onUpdateMock).toHaveBeenCalledTimes(10);
+ onUpdateMock.mock.calls.forEach(([percentage]) => {
+ expect(percentage).toBeLessThanOrEqual(99);
+ });
+
+ // Stop animation
+ stopAnimation();
+ });
+});
+
+beforeAll(() => {
+ jest.useFakeTimers();
+});
+
+afterAll(() => {
+ jest.useRealTimers();
+});
diff --git a/packages/manager/src/utilities/percentageCounter.ts b/packages/manager/src/utilities/percentageCounter.ts
new file mode 100644
index 00000000000..ada42b4cf6d
--- /dev/null
+++ b/packages/manager/src/utilities/percentageCounter.ts
@@ -0,0 +1,54 @@
+/**
+ * This function provides a mechanism to display a percentage based progress.
+ * It helps the user with a better visual indication that an action is still processing.
+ * It could be used for instance to display a percentage progress or fill a progress bar.
+ *
+ * It accepts an arbitrary timer, based on a either a time based action or an estimation a task may take.
+ *
+ * In the case of an API request, it should only be used for requests that takes a long time, such as One Click Apps StackScripts.
+ * The idea behind it is that if the request takes shorter than the timer, then great, otherwise it may stay at 99% a tad longer till resolved.
+ * In this case it is purely an approximate visual aid.
+ *
+ * For the purpose of it being versatile, it avoids hooks so it can be used in any component.
+ *
+ * @param duration {number}
+ * @returns {startAnimation, stopAnimation}
+ */
+export const initializePercentageCounter = (duration: number) => {
+ let startTimestamp: number;
+ let animationFrameId: number;
+ let percentage: number = 0;
+ let onUpdate: (percentage: number) => void;
+
+ const animatePercentage = (timestamp: number) => {
+ if (!startTimestamp) {
+ startTimestamp = timestamp;
+ }
+
+ const progress = timestamp - startTimestamp;
+ // Here we're upping the percentage to 99 max
+ // It provides an indication the request isn't fully complete
+ // Since it's an arbitrary timer, we don't want to show 100% while not complete
+ // It will appear "Stuck" at 99% till resolved
+ const stopAtPercentage = 99;
+ percentage = Math.min(stopAtPercentage, (progress / duration) * 100);
+ onUpdate(percentage);
+
+ if (percentage < stopAtPercentage) {
+ animationFrameId = requestAnimationFrame(animatePercentage);
+ }
+ };
+
+ const startAnimation = (updateCallback: (percentage: number) => void) => {
+ onUpdate = updateCallback;
+ animationFrameId = requestAnimationFrame(animatePercentage);
+ };
+
+ const stopAnimation = () => {
+ if (animationFrameId) {
+ cancelAnimationFrame(animationFrameId);
+ }
+ };
+
+ return { startAnimation, stopAnimation };
+};