diff --git a/packages/manager/.changeset/pr-11215-changed-1738603808282.md b/packages/manager/.changeset/pr-11215-changed-1738603808282.md
new file mode 100644
index 00000000000..9b4e005fe83
--- /dev/null
+++ b/packages/manager/.changeset/pr-11215-changed-1738603808282.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Changed
+---
+
+Refactor StackScripts landing page ([#11215](https://github.com/linode/manager/pull/11215))
diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts
index 4ae8be80f21..757533ee169 100644
--- a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts
+++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts
@@ -1,8 +1,7 @@
import type { StackScript } from '@linode/api-v4';
-import { Profile, getImages, getProfile } from '@linode/api-v4';
+import { Profile, getProfile } from '@linode/api-v4';
import { stackScriptFactory } from 'src/factories';
-import { isLinodeKubeImageId } from 'src/store/image/image.helpers';
import { formatDate } from 'src/utilities/formatDate';
import { authenticate } from 'support/api/authentication';
@@ -15,12 +14,9 @@ import {
} from 'support/intercepts/stackscripts';
import { ui } from 'support/ui';
import { cleanUp } from 'support/util/cleanup';
-import { depaginate } from 'support/util/paginate';
import { randomLabel, randomString } from 'support/util/random';
import { chooseRegion } from 'support/util/regions';
-import type { Image } from '@linode/api-v4';
-
const mockStackScripts: StackScript[] = [
stackScriptFactory.build({
id: 443929,
@@ -106,7 +102,10 @@ describe('Community Stackscripts integration tests', () => {
cy.visitWithLogin('/stackscripts/community');
cy.wait('@getStackScripts');
- cy.get('[data-qa-stackscript-empty-msg="true"]').should('not.exist');
+ // Confirm that empty state is not shown.
+ cy.get('[data-qa-placeholder-container="resources-section"]').should(
+ 'not.exist'
+ );
cy.findByText('Automate deployment scripts').should('not.exist');
cy.defer(getProfile, 'getting profile').then((profile: Profile) => {
@@ -138,7 +137,7 @@ describe('Community Stackscripts integration tests', () => {
// Search the corresponding community stack script
mockGetStackScripts([stackScript]).as('getFilteredStackScripts');
- cy.get('[id="search-by-label,-username,-or-description"]')
+ cy.findByPlaceholderText('Search by Label, Username, or Description')
.click()
.type(`${stackScript.label}{enter}`);
cy.wait('@getFilteredStackScripts');
@@ -194,69 +193,39 @@ describe('Community Stackscripts integration tests', () => {
interceptGetStackScripts().as('getStackScripts');
// Fetch all public Images to later use while filtering StackScripts.
- cy.defer(() =>
- depaginate((page) => getImages({ page }, { is_public: true }))
- ).then((publicImages: Image[]) => {
- cy.visitWithLogin('/stackscripts/community');
- cy.wait('@getStackScripts');
-
- // Confirm that empty state is not shown.
- cy.get('[data-qa-stackscript-empty-msg="true"]').should('not.exist');
- cy.findByText('Automate deployment scripts').should('not.exist');
-
- // Confirm that scrolling to the bottom of the StackScripts list causes
- // pagination to occur automatically. Perform this check 3 times.
- for (let i = 0; i < 3; i += 1) {
- cy.findByLabelText('List of StackScripts')
- .should('be.visible')
- .within(() => {
- // Scroll to the bottom of the StackScripts list, confirm Cloud fetches StackScripts,
- // then confirm that list updates with the new StackScripts shown.
- cy.get('tr').last().scrollIntoView();
- cy.wait('@getStackScripts').then((xhr) => {
- const stackScripts = xhr.response?.body['data'] as
- | StackScript[]
- | undefined;
-
- if (!stackScripts) {
- throw new Error(
- 'Unexpected response received when fetching StackScripts'
- );
- }
-
- // Cloud Manager hides certain StackScripts from the landing page (although they can
- // still be found via search). It does this if either condition is met:
- //
- // - The StackScript is only compatible with deprecated Images
- // - The StackScript is only compatible with LKE Images
- //
- // As a consequence, we can't use the API response directly to assert
- // that content is shown in the list. We need to apply identical filters
- // to the response first, then assert the content using that data.
- const filteredStackScripts = stackScripts.filter(
- (stackScript: StackScript) => {
- const hasNonDeprecatedImages = stackScript.images.some(
- (stackScriptImage) => {
- return !!publicImages.find(
- (publicImage) => publicImage.id === stackScriptImage
- );
- }
- );
-
- const usesKubeImage = stackScript.images.some(
- (stackScriptImage) => isLinodeKubeImageId(stackScriptImage)
- );
- return hasNonDeprecatedImages && !usesKubeImage;
- }
- );
+ cy.visitWithLogin('/stackscripts/community');
+ cy.wait('@getStackScripts');
- cy.contains(
- `${filteredStackScripts[0].username} / ${filteredStackScripts[0].label}`
- ).should('be.visible');
- });
- });
- }
- });
+ // Confirm that empty state is not shown.
+ cy.get('[data-qa-placeholder-container="resources-section"]').should(
+ 'not.exist'
+ );
+ cy.findByText('Automate deployment scripts').should('not.exist');
+
+ // Confirm that scrolling to the bottom of the StackScripts list causes
+ // pagination to occur automatically. Perform this check 3 times.
+ for (let i = 0; i < 3; i += 1) {
+ cy.findByLabelText('List of StackScripts')
+ .should('be.visible')
+ .within(() => {
+ // Scroll to the bottom of the StackScripts list, confirm Cloud fetches StackScripts,
+ // then confirm that list updates with the new StackScripts shown.
+ cy.get('tr').last().scrollIntoView();
+ cy.wait('@getStackScripts').then((xhr) => {
+ const stackScripts = xhr.response?.body['data'] as
+ | StackScript[]
+ | undefined;
+
+ if (!stackScripts) {
+ throw new Error(
+ 'Unexpected response received when fetching StackScripts'
+ );
+ }
+
+ cy.contains(`${stackScripts[0].username} / ${stackScripts[0].label}`).should('be.visible');
+ });
+ });
+ }
});
/*
@@ -271,13 +240,16 @@ describe('Community Stackscripts integration tests', () => {
cy.visitWithLogin('/stackscripts/community');
cy.wait('@getStackScripts');
- cy.get('[data-qa-stackscript-empty-msg="true"]').should('not.exist');
+ // Confirm that empty state is not shown.
+ cy.get('[data-qa-placeholder-container="resources-section"]').should(
+ 'not.exist'
+ );
cy.findByText('Automate deployment scripts').should('not.exist');
cy.get('tr').then((value) => {
const rowCount = Cypress.$(value).length - 1; // Remove the table title row
- cy.get('[id="search-by-label,-username,-or-description"]')
+ cy.findByPlaceholderText('Search by Label, Username, or Description')
.click()
.type(`${stackScript.label}{enter}`);
cy.get(`[data-qa-table-row="${stackScript.label}"]`).should('be.visible');
@@ -311,7 +283,7 @@ describe('Community Stackscripts integration tests', () => {
cy.visitWithLogin('/stackscripts/community');
cy.wait(['@getStackScripts', '@getPreferences']);
- cy.get('[id="search-by-label,-username,-or-description"]')
+ cy.findByPlaceholderText('Search by Label, Username, or Description')
.click()
.type(`${stackScriptName}{enter}`);
cy.get(`[data-qa-table-row="${stackScriptName}"]`)
diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts
index dd039c7bb1d..ee132f35ea3 100644
--- a/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts
+++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts
@@ -15,7 +15,8 @@ describe('Display stackscripts', () => {
cy.wait('@getStackScripts');
cy.findByText('Automate deployment scripts').should('be.visible');
- cy.get('[data-qa-stackscript-empty-msg="true"]')
+
+ cy.get('[data-qa-placeholder-container="resources-section"]')
.should('be.visible')
.within(() => {
ui.button
diff --git a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts
index 4bb8aa8d497..da67fcc7ddd 100644
--- a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts
+++ b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts
@@ -227,7 +227,7 @@ describe('Update stackscripts', () => {
.should('be.visible')
.click();
ui.dialog
- .findByTitle('Woah, just a word of caution...')
+ .findByTitle(`Make StackScript ${stackScripts[0].label} Public?`)
.should('be.visible')
.within(() => {
ui.button.findByTitle('Cancel').should('be.visible').click();
@@ -262,11 +262,11 @@ describe('Update stackscripts', () => {
'mockGetStackScripts'
);
ui.dialog
- .findByTitle('Woah, just a word of caution...')
+ .findByTitle(`Make StackScript ${stackScripts[0].label} Public?`)
.should('be.visible')
.within(() => {
ui.button
- .findByTitle('Yes, make me a star!')
+ .findByTitle('Confirm')
.should('be.visible')
.click();
});
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionList.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionList.tsx
index 0af87d0a568..c90ce9f0388 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionList.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionList.tsx
@@ -1,5 +1,4 @@
import { getAPIFilterFromQuery } from '@linode/search';
-import { Typography } from '@linode/ui';
import {
Box,
Button,
@@ -17,7 +16,6 @@ import { useController, useFormContext } from 'react-hook-form';
import { Waypoint } from 'react-waypoint';
import { debounce } from 'throttle-debounce';
-import { Code } from 'src/components/Code/Code';
import { Table } from 'src/components/Table';
import { TableBody } from 'src/components/TableBody';
import { TableCell } from 'src/components/TableCell/TableCell';
@@ -27,6 +25,7 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty';
import { TableRowError } from 'src/components/TableRowError/TableRowError';
import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading';
import { TableSortCell } from 'src/components/TableSortCell';
+import { StackScriptSearchHelperText } from 'src/features/StackScripts/Partials/StackScriptSearchHelperText';
import { useOrder } from 'src/hooks/useOrder';
import {
useStackScriptQuery,
@@ -176,36 +175,12 @@ export const StackScriptSelectionList = ({ type }: Props) => {
),
}}
- tooltipText={
-
-
- You can search for a specific item by prepending your search term
- with "username:", "label:", or "description:".
-
-
- theme.font.bold}>
- Examples
-
-
- username: linode
-
-
- label: sql
-
-
- description: "ubuntu server"
-
-
- label: sql or label: php
-
-
-
- }
hideLabel
label="Search"
onChange={debounce(400, (e) => setQuery(e.target.value))}
placeholder="Search StackScripts"
spellCheck={false}
+ tooltipText={}
tooltipWidth={300}
value={query}
/>
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionRow.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionRow.tsx
index 8d9522c3b82..f362a795766 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionRow.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionRow.tsx
@@ -5,6 +5,7 @@ import React from 'react';
import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction';
import { TableCell } from 'src/components/TableCell';
import { TableRow } from 'src/components/TableRow';
+import { isLKEStackScript } from 'src/features/StackScripts/stackScriptUtils';
import { truncate } from 'src/utilities/truncate';
import type { StackScript } from '@linode/api-v4';
@@ -20,9 +21,9 @@ interface Props {
export const StackScriptSelectionRow = (props: Props) => {
const { disabled, isSelected, onOpenDetails, onSelect, stackscript } = props;
- // Never show LKE StackScripts. We try to hide these from the user, even though they
+ // Never show LKE StackScripts. We try to hide these from the user even though they
// are returned by the API.
- if (stackscript.username.startsWith('lke-service-account-')) {
+ if (isLKEStackScript(stackscript)) {
return null;
}
diff --git a/packages/manager/src/features/StackScripts/CommonStackScript.styles.ts b/packages/manager/src/features/StackScripts/CommonStackScript.styles.ts
index 2df739d466a..a2f42c3462d 100644
--- a/packages/manager/src/features/StackScripts/CommonStackScript.styles.ts
+++ b/packages/manager/src/features/StackScripts/CommonStackScript.styles.ts
@@ -1,10 +1,8 @@
import { Button, Typography } from '@linode/ui';
import { styled } from '@mui/material/styles';
import Grid from '@mui/material/Unstable_Grid2';
-import { Link } from 'react-router-dom';
import { TableCell } from 'src/components/TableCell';
-import { TableRow } from 'src/components/TableRow';
import type { Theme } from '@mui/material/styles';
@@ -12,47 +10,6 @@ const libRadioLabel = {
cursor: 'pointer',
};
-const stackScriptUsernameStyling = (theme: Theme) => {
- return {
- ...libRadioLabel,
- color: theme.textColors.tableStatic,
- fontSize: '0.875rem',
- lineHeight: '1.125rem',
- };
-};
-
-const rowStyles = {
- '& > button': {
- height: 46,
- },
- height: 46,
-};
-
-const libTitle = (theme: Theme) => {
- return {
- fontSize: '0.875rem',
- lineHeight: '1.125rem',
- overflow: 'hidden',
- textOverflow: 'ellipsis',
- [theme.breakpoints.down('md')]: {
- wordBreak: 'break-all',
- },
- whiteSpace: 'nowrap' as const,
- };
-};
-
-export const StyledTitleTypography = styled(Typography, {
- label: 'StyledTitleTypography',
-})(({ theme }) => ({
- ...libTitle(theme),
-}));
-
-export const StyledTitleTableCell = styled(TableCell, {
- label: 'StyledTitleTableCell',
-})(({ theme }) => ({
- ...libTitle(theme),
-}));
-
export const StyledDetailsButton = styled(Button, {
label: 'StyledDetailsButton',
})(({ theme }) => ({
@@ -124,16 +81,6 @@ export const StyledImagesTableCell = styled(TableCell, {
whiteSpace: 'pre-wrap',
});
-export const StyledTableRow = styled(TableRow, { label: 'StyledTableRow' })({
- ...rowStyles,
-});
-
-export const StyledRowTableCell = styled(TableCell, {
- label: 'StyledRowTableCell',
-})({
- ...rowStyles,
-});
-
export const StyledTypography = styled(Typography, {
label: 'StyledTypography',
})(({ theme }) => ({
@@ -147,28 +94,21 @@ export const StyledTypography = styled(Typography, {
whiteSpace: 'nowrap',
}));
+const stackScriptUsernameStyling = (theme: Theme) => {
+ return {
+ ...libRadioLabel,
+ color: theme.textColors.tableStatic,
+ fontSize: '0.875rem',
+ lineHeight: '1.125rem',
+ };
+};
+
export const StyledUsernameLabel = styled('label', {
label: 'StyledUsernameLabel',
})(({ theme }) => ({
...stackScriptUsernameStyling(theme),
}));
-export const StyledLabelSpan = styled('span', { label: 'StyledLabelSpan' })({
- ...libRadioLabel,
-});
-
-export const StyledUsernameSpan = styled('span', {
- label: 'StyledUsernameSpan',
-})(({ theme }) => ({
- ...stackScriptUsernameStyling(theme),
-}));
-
-export const StyledLink = styled(Link, { label: 'StyledLink' })(
- ({ theme }) => ({
- color: theme.textColors.tableStatic,
- })
-);
-
export const StyledStackScriptSectionTableCell = styled(TableCell, {
label: 'StyledStackScriptSectionTableCell',
})({
diff --git a/packages/manager/src/features/StackScripts/Partials/StackScriptSearchHelperText.tsx b/packages/manager/src/features/StackScripts/Partials/StackScriptSearchHelperText.tsx
new file mode 100644
index 00000000000..3d764640cf9
--- /dev/null
+++ b/packages/manager/src/features/StackScripts/Partials/StackScriptSearchHelperText.tsx
@@ -0,0 +1,32 @@
+import { Box, Stack, Typography } from '@linode/ui';
+import React from 'react';
+
+import { Code } from 'src/components/Code/Code';
+
+export const StackScriptSearchHelperText = () => {
+ return (
+
+
+ You can search for a specific item by prepending your search term with
+ "username:", "label:", or "description:".
+
+
+ theme.font.bold}>
+ Examples
+
+
+ username: linode
+
+
+ label: sql
+
+
+ description: "ubuntu server"
+
+
+ label: sql or label: php
+
+
+
+ );
+};
diff --git a/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx b/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx
index b02b489f9be..e0dbcdfb5fc 100644
--- a/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx
+++ b/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx
@@ -231,11 +231,6 @@ const withStackScriptBase = (options: WithStackScriptBaseOptions) => (
);
};
- goToCreateStackScript = () => {
- const { history } = this.props;
- history.push('/stackscripts/create');
- };
-
handleClickTableHeader = (value: string) => {
const { currentSearchFilter, sortOrder } = this.state;
@@ -496,9 +491,7 @@ const withStackScriptBase = (options: WithStackScriptBaseOptions) => (
You don’t have any StackScripts to select from.
) : (
-
+
)}
) : (
diff --git a/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptsEmptyLandingPage.tsx b/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptsEmptyLandingPage.tsx
index b7145c0ea10..20580074dc0 100644
--- a/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptsEmptyLandingPage.tsx
+++ b/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptsEmptyLandingPage.tsx
@@ -1,7 +1,10 @@
import * as React from 'react';
+import { useHistory } from 'react-router-dom';
import LinodeIcon from 'src/assets/icons/entityIcons/linode.svg';
import { ResourcesSection } from 'src/components/EmptyLandingPageResources/ResourcesSection';
+import { getRestrictedResourceText } from 'src/features/Account/utils';
+import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck';
import { sendEvent } from 'src/utilities/analytics/utils';
import {
@@ -11,26 +14,32 @@ import {
youtubeLinkData,
} from './StackScriptsEmptyResourcesData';
-interface Props {
- goToCreateStackScript: () => void;
-}
+export const StackScriptsEmptyLandingState = () => {
+ const history = useHistory();
-export const StackScriptsEmptyLandingState = (props: Props) => {
- const { goToCreateStackScript } = props;
+ const isStackScriptCreationRestricted = useRestrictedGlobalGrantCheck({
+ globalGrantType: 'add_stackscripts',
+ });
return (
{
sendEvent({
action: 'Click:button',
category: linkAnalyticsEvent.category,
label: 'Create StackScript',
});
- goToCreateStackScript();
+ history.push('/stackscripts/create');
},
+ tooltipText: getRestrictedResourceText({
+ action: 'create',
+ isSingular: false,
+ resourceType: 'StackScripts',
+ }),
},
]}
gettingStartedGuidesData={gettingStartedGuides}
diff --git a/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptActionMenu.tsx b/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptActionMenu.tsx
new file mode 100644
index 00000000000..589911e4cdb
--- /dev/null
+++ b/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptActionMenu.tsx
@@ -0,0 +1,118 @@
+import { useMediaQuery } from '@mui/material';
+import React from 'react';
+import { useHistory } from 'react-router-dom';
+
+import { ActionMenu } from 'src/components/ActionMenu/ActionMenu';
+import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction';
+import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted';
+import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck';
+
+import type { StackScript } from '@linode/api-v4';
+import type { Theme } from '@mui/material';
+import type { Action } from 'src/components/ActionMenu/ActionMenu';
+
+export interface StackScriptHandlers {
+ onDelete: () => void;
+ onMakePublic: () => void;
+}
+
+interface Props {
+ handlers: StackScriptHandlers;
+ stackscript: StackScript;
+ type: 'account' | 'community';
+}
+
+export const StackScriptActionMenu = (props: Props) => {
+ const { handlers, stackscript, type } = props;
+
+ const history = useHistory();
+
+ const isLargeScreen = useMediaQuery((theme) =>
+ theme.breakpoints.up('md')
+ );
+
+ const isLinodeCreationRestricted = useRestrictedGlobalGrantCheck({
+ globalGrantType: 'add_linodes',
+ permittedGrantLevel: 'read_write',
+ });
+
+ const isStackScriptReadOnly = useIsResourceRestricted({
+ grantLevel: 'read_only',
+ grantType: 'stackscript',
+ id: stackscript.id,
+ });
+
+ const sharedActionOptions = isStackScriptReadOnly
+ ? {
+ disabled: true,
+ tooltip: "You don't have permissions to modify this StackScript",
+ }
+ : {};
+
+ const actions: { action: Action; show: boolean }[] = [
+ {
+ action: {
+ onClick: () => history.push(`/stackscripts/${stackscript.id}/edit`),
+ title: 'Edit',
+ ...sharedActionOptions,
+ },
+ show: type === 'account',
+ },
+ {
+ action: {
+ disabled: isLinodeCreationRestricted,
+ onClick: () =>
+ history.push(
+ type === 'account'
+ ? `/linodes/create?type=StackScripts&subtype=Account&stackScriptID=${stackscript.id}`
+ : `/linodes/create?type=StackScripts&subtype=Community&stackScriptID=${stackscript.id}`
+ ),
+ title: 'Deploy New Linode',
+ tooltip: isLinodeCreationRestricted
+ ? "You don't have permissions to add Linodes"
+ : undefined,
+ },
+ show: true,
+ },
+ {
+ action: {
+ onClick: handlers.onMakePublic,
+ title: 'Make StackScript Public',
+ ...sharedActionOptions,
+ },
+ show: !stackscript.is_public,
+ },
+ {
+ action: {
+ onClick: handlers.onDelete,
+ title: 'Delete',
+ ...sharedActionOptions,
+ },
+ show: !stackscript.is_public,
+ },
+ ];
+
+ const filteredActions = actions.reduce((acc, action) => {
+ if (action.show) {
+ acc.push(action.action);
+ }
+ return acc;
+ }, []);
+
+ if (type === 'community' && isLargeScreen) {
+ return filteredActions.map((action) => (
+
+ ));
+ }
+
+ return (
+
+ );
+};
diff --git a/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptDeleteDialog.tsx b/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptDeleteDialog.tsx
new file mode 100644
index 00000000000..b5dbd1f7fbc
--- /dev/null
+++ b/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptDeleteDialog.tsx
@@ -0,0 +1,55 @@
+import { Button, Stack, Typography } from '@linode/ui';
+import { useSnackbar } from 'notistack';
+import React from 'react';
+
+import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog';
+import { useDeleteStackScriptMutation } from 'src/queries/stackscripts';
+
+import type { StackScript } from '@linode/api-v4';
+
+interface Props {
+ onClose: () => void;
+ open: boolean;
+ stackscript: StackScript | undefined;
+}
+
+export const StackScriptDeleteDialog = (props: Props) => {
+ const { onClose, open, stackscript } = props;
+ const { enqueueSnackbar } = useSnackbar();
+
+ const { error, isPending, mutate } = useDeleteStackScriptMutation(
+ stackscript?.id ?? -1,
+ {
+ onSuccess() {
+ enqueueSnackbar({
+ message: `Successfully deleted StackScript.`,
+ variant: 'success',
+ });
+ onClose();
+ },
+ }
+ );
+
+ return (
+
+
+
+
+ }
+ error={error?.[0].reason}
+ onClose={onClose}
+ open={open}
+ title={`Delete StackScript ${stackscript?.label}?`}
+ >
+ Are you sure you want to delete this StackScript?
+
+ );
+};
diff --git a/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptLandingTable.tsx b/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptLandingTable.tsx
new file mode 100644
index 00000000000..36d677d782c
--- /dev/null
+++ b/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptLandingTable.tsx
@@ -0,0 +1,233 @@
+import { getAPIFilterFromQuery } from '@linode/search';
+import { CircleProgress, Stack, TooltipIcon } from '@linode/ui';
+import React, { useState } from 'react';
+import { useHistory } from 'react-router-dom';
+import { Waypoint } from 'react-waypoint';
+
+import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField';
+import { ErrorState } from 'src/components/ErrorState/ErrorState';
+import { Hidden } from 'src/components/Hidden';
+import { Table } from 'src/components/Table';
+import { TableBody } from 'src/components/TableBody';
+import { TableCell } from 'src/components/TableCell';
+import { TableHead } from 'src/components/TableHead';
+import { TableRow } from 'src/components/TableRow';
+import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty';
+import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading';
+import { TableSortCell } from 'src/components/TableSortCell';
+import {
+ accountStackScriptFilter,
+ communityStackScriptFilter,
+} from 'src/features/Linodes/LinodeCreate/Tabs/StackScripts/utilities';
+import { useOrder } from 'src/hooks/useOrder';
+import { useStackScriptsInfiniteQuery } from 'src/queries/stackscripts';
+
+import { StackScriptSearchHelperText } from '../Partials/StackScriptSearchHelperText';
+import { StackScriptsEmptyLandingState } from '../StackScriptBase/StackScriptsEmptyLandingPage';
+import { isLKEStackScript } from '../stackScriptUtils';
+import { StackScriptDeleteDialog } from './StackScriptDeleteDialog';
+import { StackScriptMakePublicDialog } from './StackScriptMakePublicDialog';
+import { StackScriptRow } from './StackScriptRow';
+
+import type { StackScript } from '@linode/api-v4';
+
+interface Props {
+ type: 'account' | 'community';
+}
+
+export const StackScriptLandingTable = (props: Props) => {
+ const { type } = props;
+
+ const filter =
+ type === 'community'
+ ? communityStackScriptFilter
+ : accountStackScriptFilter;
+
+ const defaultOrder =
+ type === 'community'
+ ? { order: 'desc' as const, orderBy: 'deployments_total' }
+ : { order: 'desc' as const, orderBy: 'updated' };
+
+ const history = useHistory();
+
+ const queryParams = new URLSearchParams(history.location.search);
+ const query = queryParams.get('query') ?? '';
+
+ const {
+ error: searchParseError,
+ filter: searchFilter,
+ } = getAPIFilterFromQuery(query, {
+ searchableFieldsWithoutOperator: ['username', 'label', 'description'],
+ });
+
+ const { handleOrderChange, order, orderBy } = useOrder(defaultOrder);
+
+ const [selectedStackScriptId, setSelectedStackScriptId] = useState();
+ const [isMakePublicDialogOpen, setIsMakePublicDialogOpen] = useState(false);
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+
+ const {
+ data,
+ error,
+ fetchNextPage,
+ hasNextPage,
+ isFetching,
+ isFetchingNextPage,
+ isLoading,
+ } = useStackScriptsInfiniteQuery({
+ ...filter,
+ ...searchFilter,
+ '+order': order,
+ '+order_by': orderBy,
+ });
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (error) {
+ return ;
+ }
+
+ // Never show LKE StackScripts. We try to hide these from the user even though they
+ // are returned by the API.
+ const stackscripts = data?.pages.reduce((acc, page) => {
+ for (const stackscript of page.data) {
+ if (!isLKEStackScript(stackscript)) {
+ acc.push(stackscript);
+ }
+ }
+ return acc;
+ }, []);
+
+ if (!query && stackscripts?.length === 0) {
+ return ;
+ }
+
+ const selectedStackScript = selectedStackScriptId
+ ? stackscripts?.find((s) => s.id === selectedStackScriptId)
+ : undefined;
+
+ return (
+
+
+ ),
+ }
+ : {}
+ }
+ onSearch={(value) => {
+ queryParams.set('query', value);
+ history.push({ search: queryParams.toString() });
+ }}
+ clearable
+ hideLabel
+ isSearching={isFetching}
+ label="Search"
+ noMarginTop
+ placeholder="Search by Label, Username, or Description"
+ tooltipText={}
+ tooltipWidth={300}
+ value={query}
+ />
+
+
+
+
+ StackScript
+
+
+ Deploys
+
+
+
+ Last Revision
+
+
+
+ Compatible Images
+
+ {type === 'account' && (
+
+ Status
+
+ )}
+
+
+
+
+ {stackscripts?.map((stackscript) => (
+ {
+ setSelectedStackScriptId(stackscript.id);
+ setIsDeleteDialogOpen(true);
+ },
+ onMakePublic: () => {
+ setSelectedStackScriptId(stackscript.id);
+ setIsMakePublicDialogOpen(true);
+ },
+ }}
+ key={stackscript.id}
+ stackscript={stackscript}
+ type={type}
+ />
+ ))}
+ {query && stackscripts?.length === 0 && }
+ {isFetchingNextPage && (
+
+ )}
+
+
+ {hasNextPage && fetchNextPage()} />}
+ setIsMakePublicDialogOpen(false)}
+ open={isMakePublicDialogOpen}
+ stackscript={selectedStackScript}
+ />
+ setIsDeleteDialogOpen(false)}
+ open={isDeleteDialogOpen}
+ stackscript={selectedStackScript}
+ />
+
+ );
+};
diff --git a/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptMakePublicDialog.tsx b/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptMakePublicDialog.tsx
new file mode 100644
index 00000000000..a08f32e47d6
--- /dev/null
+++ b/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptMakePublicDialog.tsx
@@ -0,0 +1,59 @@
+import { Button, Stack, Typography } from '@linode/ui';
+import { useSnackbar } from 'notistack';
+import React from 'react';
+
+import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog';
+import { useUpdateStackScriptMutation } from 'src/queries/stackscripts';
+
+import type { StackScript } from '@linode/api-v4';
+
+interface Props {
+ onClose: () => void;
+ open: boolean;
+ stackscript: StackScript | undefined;
+}
+
+export const StackScriptMakePublicDialog = (props: Props) => {
+ const { onClose, open, stackscript } = props;
+ const { enqueueSnackbar } = useSnackbar();
+
+ const { error, isPending, mutate } = useUpdateStackScriptMutation(
+ stackscript?.id ?? -1,
+ {
+ onSuccess(stackscript) {
+ enqueueSnackbar({
+ message: `${stackscript.label} successfully published to the public library.`,
+ variant: 'success',
+ });
+ onClose();
+ },
+ }
+ );
+
+ return (
+
+
+
+
+ }
+ error={error?.[0].reason}
+ onClose={onClose}
+ open={open}
+ title={`Make StackScript ${stackscript?.label ?? ''} Public?`}
+ >
+
+ Are you sure you want to make {stackscript?.label} public? This action
+ cannot be undone, nor will you be able to delete the StackScript once
+ made available to the public.
+
+
+ );
+};
diff --git a/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptRow.tsx b/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptRow.tsx
new file mode 100644
index 00000000000..17ceda1a2e1
--- /dev/null
+++ b/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptRow.tsx
@@ -0,0 +1,77 @@
+import { Stack, Typography } from '@linode/ui';
+import React from 'react';
+
+import { DateTimeDisplay } from 'src/components/DateTimeDisplay';
+import { Hidden } from 'src/components/Hidden';
+import { Link } from 'src/components/Link';
+import { TableCell } from 'src/components/TableCell';
+import { TableRow } from 'src/components/TableRow';
+
+import { getStackScriptImages } from '../stackScriptUtils';
+import { StackScriptActionMenu } from './StackScriptActionMenu';
+
+import type { StackScriptHandlers } from './StackScriptActionMenu';
+import type { StackScript } from '@linode/api-v4';
+
+interface Props {
+ handlers: StackScriptHandlers;
+ stackscript: StackScript;
+ type: 'account' | 'community';
+}
+
+export const StackScriptRow = (props: Props) => {
+ const { handlers, stackscript, type } = props;
+
+ return (
+
+
+
+
+
+ {stackscript.username} / {stackscript.label}
+
+
+ ({
+ color: theme.textColors.tableHeader,
+ fontSize: '.75rem',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ })}
+ >
+ {stackscript.description}
+
+
+
+ {stackscript.deployments_total}
+
+
+
+
+
+
+
+ {getStackScriptImages(stackscript.images)}
+
+
+ {type === 'account' && (
+
+ {stackscript.is_public ? 'Public' : 'Private'}
+
+ )}
+
+
+
+
+ );
+};
diff --git a/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptsLanding.tsx b/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptsLanding.tsx
new file mode 100644
index 00000000000..c4a0da7dea8
--- /dev/null
+++ b/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptsLanding.tsx
@@ -0,0 +1,64 @@
+import { createLazyRoute } from '@tanstack/react-router';
+import * as React from 'react';
+import { useHistory } from 'react-router-dom';
+
+import { DocumentTitleSegment } from 'src/components/DocumentTitle';
+import { LandingHeader } from 'src/components/LandingHeader';
+import { NavTabs } from 'src/components/NavTabs/NavTabs';
+import { getRestrictedResourceText } from 'src/features/Account/utils';
+import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck';
+
+import { StackScriptLandingTable } from './StackScriptLandingTable';
+
+import type { NavTab } from 'src/components/NavTabs/NavTabs';
+
+export const StackScriptsLanding = () => {
+ const history = useHistory();
+
+ const isStackScriptCreationRestricted = useRestrictedGlobalGrantCheck({
+ globalGrantType: 'add_stackscripts',
+ });
+
+ const tabs: NavTab[] = [
+ {
+ render: ,
+ routeName: `/stackscripts/account`,
+ title: 'Account StackScripts',
+ },
+ {
+ render: ,
+ routeName: `/stackscripts/community`,
+ title: 'Community StackScripts',
+ },
+ ];
+
+ return (
+
+
+ {
+ history.push('/stackscripts/create');
+ }}
+ disabledCreateButton={isStackScriptCreationRestricted}
+ docsLink="https://techdocs.akamai.com/cloud-computing/docs/stackscripts"
+ entity="StackScript"
+ removeCrumbX={1}
+ title="StackScripts"
+ />
+
+
+ );
+};
+
+export default StackScriptsLanding;
+
+export const stackScriptsLandingLazyRoute = createLazyRoute('/stackscripts')({
+ component: StackScriptsLanding,
+});
diff --git a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx
deleted file mode 100644
index 6fd4ddfb56b..00000000000
--- a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx
+++ /dev/null
@@ -1,130 +0,0 @@
-import { Theme, useTheme } from '@mui/material/styles';
-import useMediaQuery from '@mui/material/useMediaQuery';
-import * as React from 'react';
-import { useHistory } from 'react-router-dom';
-
-import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu';
-import { Hidden } from 'src/components/Hidden';
-import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction';
-import { useProfile } from 'src/queries/profile/profile';
-
-import { StackScriptCategory, getStackScriptUrl } from '../stackScriptUtils';
-
-interface Props {
- canAddLinodes: boolean;
- canModify: boolean;
- // change until we're actually using it.
- category: StackScriptCategory | string;
- isHeader?: boolean;
- isPublic: boolean;
- stackScriptID: number;
- stackScriptLabel: string;
- stackScriptUsername: string;
- // @todo: when we implement StackScripts pagination, we should remove "| string" in the type below.
- // Leaving this in as an escape hatch now, since there's a bunch of code in
- // /LandingPanel that uses different values for categories that we shouldn't
- triggerDelete: (id: number, label: string) => void;
- triggerMakePublic: (id: number, label: string) => void;
-}
-
-export const StackScriptActionMenu = (props: Props) => {
- const theme = useTheme();
- const matchesSmDown = useMediaQuery(theme.breakpoints.down('md'));
- const { data: profile } = useProfile();
- const history = useHistory();
-
- const {
- canAddLinodes,
- canModify,
- category,
- isPublic,
- stackScriptID,
- stackScriptLabel,
- stackScriptUsername,
- triggerDelete,
- triggerMakePublic,
- } = props;
-
- const readonlyProps = {
- disabled: !canModify,
- tooltip: !canModify
- ? "You don't have permissions to modify this StackScript"
- : undefined,
- };
-
- const actions = [
- // We only add the "Edit" option if the current tab/category isn't
- // "Community StackScripts". A user's own public StackScripts are still
- // editable under "Account StackScripts".
- category === 'account'
- ? {
- title: 'Edit',
- ...readonlyProps,
- onClick: () => {
- history.push(`/stackscripts/${stackScriptID}/edit`);
- },
- }
- : null,
- {
- disabled: !canAddLinodes,
- onClick: () => {
- history.push(
- getStackScriptUrl(
- stackScriptUsername,
- stackScriptID,
- profile?.username
- )
- );
- },
- title: 'Deploy New Linode',
- tooltip: matchesSmDown
- ? !canAddLinodes
- ? "You don't have permissions to add Linodes"
- : undefined
- : undefined,
- },
- !isPublic
- ? {
- title: 'Make StackScript Public',
- ...readonlyProps,
- onClick: () => {
- triggerMakePublic(stackScriptID, stackScriptLabel);
- },
- }
- : null,
- !isPublic
- ? {
- title: 'Delete',
- ...readonlyProps,
- onClick: () => {
- triggerDelete(stackScriptID, stackScriptLabel);
- },
- }
- : null,
- ].filter(Boolean) as Action[];
-
- return (
- // eslint-disable-next-line react/jsx-no-useless-fragment
- <>
- {category === 'account' || matchesSmDown ? (
-
- ) : (
-
- {actions.map((action) => {
- return (
-
- );
- })}
-
- )}
- >
- );
-};
diff --git a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptPanel.tsx b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptPanel.tsx
deleted file mode 100644
index d8874623669..00000000000
--- a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptPanel.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-import { Image } from '@linode/api-v4/lib/images';
-import { Linode } from '@linode/api-v4/lib/linodes';
-import * as React from 'react';
-import { RouteComponentProps } from 'react-router-dom';
-import { compose } from 'recompose';
-
-import { NavTab, NavTabs } from 'src/components/NavTabs/NavTabs';
-import { RenderGuard } from 'src/components/RenderGuard';
-import { useProfile } from 'src/queries/profile/profile';
-
-import {
- getCommunityStackscripts,
- getMineAndAccountStackScripts,
-} from '../stackScriptUtils';
-
-const StackScriptPanelContent = React.lazy(
- () => import('./StackScriptPanelContent')
-);
-
-export interface ExtendedLinode extends Linode {
- heading: string;
- subHeadings: string[];
-}
-
-interface Props {
- error?: string;
- history: RouteComponentProps<{}>['history'];
- location: RouteComponentProps<{}>['location'];
- publicImages: Record;
- queryString: string;
-}
-
-interface SelectStackScriptPanelProps extends Props, RouteComponentProps<{}> {}
-
-const SelectStackScriptPanel = (props: SelectStackScriptPanelProps) => {
- const { publicImages } = props;
- const { data: profile } = useProfile();
- const username = profile?.username || '';
-
- const tabs: NavTab[] = [
- {
- render: (
-
- ),
- routeName: `/stackscripts/account`,
- title: 'Account StackScripts',
- },
- {
- render: (
-
- ),
- routeName: `/stackscripts/community`,
- title: 'Community StackScripts',
- },
- ];
-
- return ;
-};
-
-export default compose(RenderGuard)(
- SelectStackScriptPanel
-);
diff --git a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptPanelContent.tsx b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptPanelContent.tsx
deleted file mode 100644
index eaa6fd27201..00000000000
--- a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptPanelContent.tsx
+++ /dev/null
@@ -1,248 +0,0 @@
-import {
- deleteStackScript,
- updateStackScript,
-} from '@linode/api-v4/lib/stackscripts';
-import { Typography } from '@linode/ui';
-import { useSnackbar } from 'notistack';
-import * as React from 'react';
-import { compose } from 'recompose';
-
-import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel';
-import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog';
-
-import StackScriptBase from '../StackScriptBase/StackScriptBase';
-import { StackScriptsSection } from './StackScriptsSection';
-
-import type { StateProps } from '../StackScriptBase/StackScriptBase';
-import type { Image } from '@linode/api-v4/lib/images';
-import type { StackScriptsRequest } from 'src/features/StackScripts/types';
-
-interface DialogVariantProps {
- error?: string;
- open: boolean;
- submitting: boolean;
-}
-interface DialogState {
- delete: DialogVariantProps;
- makePublic: DialogVariantProps;
- stackScriptID: number | undefined;
- stackScriptLabel: string;
-}
-
-interface Props {
- category: string;
- currentUser: string;
- publicImages: Record;
- request: StackScriptsRequest;
-}
-
-interface StackScriptPanelContentProps extends Props, StateProps {}
-
-const defaultDialogState = {
- delete: {
- open: false,
- submitting: false,
- },
- makePublic: {
- open: false,
- submitting: false,
- },
- stackScriptID: undefined,
- stackScriptLabel: '',
-};
-
-export const StackScriptPanelContent = (
- props: StackScriptPanelContentProps
-) => {
- const { currentFilter } = props;
-
- const [mounted, setMounted] = React.useState(false);
- const [dialog, setDialogState] = React.useState(
- defaultDialogState
- );
-
- const { enqueueSnackbar } = useSnackbar();
-
- React.useEffect(() => {
- setMounted(true);
-
- return () => {
- setMounted(false);
- };
- }, []);
-
- const handleCloseDialog = () => {
- setDialogState({
- ...defaultDialogState,
- });
- };
-
- const handleOpenDeleteDialog = (id: number, label: string) => {
- setDialogState({
- delete: {
- open: true,
- submitting: false,
- },
- makePublic: {
- open: false,
- submitting: false,
- },
- stackScriptID: id,
- stackScriptLabel: label,
- });
- };
-
- const handleOpenMakePublicDialog = (id: number, label: string) => {
- setDialogState({
- delete: {
- open: false,
- submitting: false,
- },
- makePublic: {
- open: true,
- submitting: false,
- },
- stackScriptID: id,
- stackScriptLabel: label,
- });
- };
-
- const handleDeleteStackScript = () => {
- setDialogState({
- ...defaultDialogState,
- delete: {
- ...dialog.delete,
- error: undefined,
- submitting: true,
- },
- });
- deleteStackScript(dialog.stackScriptID!)
- .then((_) => {
- if (!mounted) {
- return;
- }
- handleCloseDialog();
- props.getDataAtPage(1, props.currentFilter, true);
- })
- .catch((e) => {
- if (!mounted) {
- return;
- }
- setDialogState({
- ...defaultDialogState,
- delete: {
- error: e[0].reason,
- open: true,
- submitting: false,
- },
- makePublic: {
- open: false,
- submitting: false,
- },
- });
- });
- };
-
- const handleMakePublic = () => {
- updateStackScript(dialog.stackScriptID!, { is_public: true })
- .then((_) => {
- if (!mounted) {
- return;
- }
- handleCloseDialog();
- enqueueSnackbar(
- `${dialog.stackScriptLabel} successfully published to the public library.`,
- { variant: 'success' }
- );
- props.getDataAtPage(1, currentFilter, true);
- })
- .catch((_) => {
- if (!mounted) {
- return;
- }
- enqueueSnackbar(
- `There was an error publishing ${dialog.stackScriptLabel} to the public library.`,
- { variant: 'error' }
- );
- handleCloseDialog();
- });
- };
-
- const renderConfirmDeleteActions = () => {
- return (
-
- );
- };
-
- const renderConfirmMakePublicActions = () => {
- return (
-
- );
- };
-
- const renderDeleteStackScriptDialog = () => {
- return (
-
-
- Are you sure you want to delete this StackScript?
-
-
- );
- };
-
- const renderMakePublicDialog = () => {
- return (
-
-
- Are you sure you want to make {dialog.stackScriptLabel} public? This
- action cannot be undone, nor will you be able to delete the
- StackScript once made available to the public.
-
-
- );
- };
-
- return (
-
-
- {renderDeleteStackScriptDialog()}
- {renderMakePublicDialog()}
-
- );
-};
-
-export default compose(
- StackScriptBase({ isSelecting: false, useQueryString: true })
-)(StackScriptPanelContent);
diff --git a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptRow.tsx b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptRow.tsx
deleted file mode 100644
index 378d84428b0..00000000000
--- a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptRow.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-import { Typography } from '@linode/ui';
-import * as React from 'react';
-
-import { Hidden } from 'src/components/Hidden';
-import { TableCell } from 'src/components/TableCell';
-import { StackScriptActionMenu } from 'src/features/StackScripts/StackScriptPanel/StackScriptActionMenu';
-
-import {
- StyledImagesTableCell,
- StyledLabelSpan,
- StyledLink,
- StyledRowTableCell,
- StyledTableRow,
- StyledTitleTableCell,
- StyledTitleTypography,
- StyledTypography,
- StyledUsernameSpan,
-} from '../CommonStackScript.styles';
-
-import type { StackScriptCategory } from 'src/features/StackScripts/stackScriptUtils';
-
-export interface Props {
- canAddLinodes: boolean;
- canModify: boolean;
- // change until we're actually using it.
- category: StackScriptCategory | string;
- deploymentsTotal: number;
- description: string;
- images: string[];
- isPublic: boolean;
- label: string;
- stackScriptID: number;
- stackScriptUsername: string;
- triggerDelete: (id: number, label: string) => void;
- triggerMakePublic: (id: number, label: string) => void;
- // @todo: when we implement StackScripts pagination, we should remove "| string" in the type below.
- // Leaving this in as an escape hatch now, since there's a bunch of code in
- // /LandingPanel that uses different values for categories that we shouldn't
- updated: string;
-}
-
-export const StackScriptRow = (props: Props) => {
- const {
- canAddLinodes,
- canModify,
- category,
- deploymentsTotal,
- description,
- images,
- isPublic,
- label,
- stackScriptID,
- stackScriptUsername,
- triggerDelete,
- triggerMakePublic,
- updated,
- } = props;
-
- const communityStackScript = category === 'community';
-
- const renderLabel = () => {
- return (
- <>
-
-
- {stackScriptUsername && (
-
- {stackScriptUsername} /
-
- )}
- {label}
-
-
- {description && (
- {description}
- )}
- >
- );
- };
-
- return (
-
-
- {renderLabel()}
-
-
- {deploymentsTotal}
-
-
-
- {updated}
-
-
-
-
- {images.includes('any/all') ? 'Any/All' : images.join(', ')}
-
-
- {communityStackScript ? null : ( // We hide the "Status" column in the "Community StackScripts" tab of the StackScripts landing page since all of those are public.
-
-
- {isPublic ? 'Public' : 'Private'}
-
-
- )}
-
-
-
-
- );
-};
diff --git a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptsSection.tsx b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptsSection.tsx
deleted file mode 100644
index dc2e431955f..00000000000
--- a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptsSection.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import { CircleProgress } from '@linode/ui';
-import * as React from 'react';
-
-import { TableBody } from 'src/components/TableBody';
-import { TableRow } from 'src/components/TableRow';
-import { canUserModifyAccountStackScript } from 'src/features/StackScripts/stackScriptUtils';
-import { useGrants, useProfile } from 'src/queries/profile/profile';
-import { formatDate } from 'src/utilities/formatDate';
-import { stripImageName } from 'src/utilities/stripImageName';
-
-import { StyledStackScriptSectionTableCell } from '../CommonStackScript.styles';
-import { StackScriptRow } from './StackScriptRow';
-
-import type { Image } from '@linode/api-v4/lib/images';
-import type { StackScript } from '@linode/api-v4/lib/stackscripts';
-import type { StackScriptCategory } from 'src/features/StackScripts/stackScriptUtils';
-
-export interface Props {
- // change until we're actually using it.
- category: StackScriptCategory | string;
- currentUser: string;
- data: StackScript[];
- isSorting: boolean;
- publicImages: Record;
- triggerDelete: (id: number, label: string) => void;
- // @todo: when we implement StackScripts pagination, we should remove "| string" in the type below.
- // Leaving this in as an escape hatch now, since there's a bunch of code in
- // /LandingPanel that uses different values for categories that we shouldn't
- triggerMakePublic: (id: number, label: string) => void;
-}
-
-export const StackScriptsSection = (props: Props) => {
- const { category, data, isSorting, triggerDelete, triggerMakePublic } = props;
-
- const { data: profile } = useProfile();
- const { data: grants } = useGrants();
-
- const isRestrictedUser = Boolean(profile?.restricted);
- const stackScriptGrants = grants?.stackscript;
- const userCannotAddLinodes = isRestrictedUser && grants?.global.add_linodes;
-
- const listStackScript = (s: StackScript) => (
-
- );
-
- return (
-
- {!isSorting ? (
- data && data.map(listStackScript)
- ) : (
-
-
-
-
-
- )}
-
- );
-};
diff --git a/packages/manager/src/features/StackScripts/StackScripts.tsx b/packages/manager/src/features/StackScripts/StackScripts.tsx
index bc3131f6912..ad92b6317df 100644
--- a/packages/manager/src/features/StackScripts/StackScripts.tsx
+++ b/packages/manager/src/features/StackScripts/StackScripts.tsx
@@ -16,9 +16,11 @@ const StackScriptsDetail = React.lazy(() =>
default: module.StackScriptDetail,
}))
);
-
-const StackScriptsLanding = React.lazy(() => import('./StackScriptsLanding'));
-
+const StackScriptsLanding = React.lazy(() =>
+ import('./StackScriptLanding/StackScriptsLanding').then((module) => ({
+ default: module.StackScriptsLanding,
+ }))
+);
const StackScriptCreate = React.lazy(() =>
import('./StackScriptCreate/StackScriptCreate').then((module) => ({
default: module.StackScriptCreate,
diff --git a/packages/manager/src/features/StackScripts/StackScriptsDetail.tsx b/packages/manager/src/features/StackScripts/StackScriptsDetail.tsx
index e9c6fb462ce..bffe9dd71c8 100644
--- a/packages/manager/src/features/StackScripts/StackScriptsDetail.tsx
+++ b/packages/manager/src/features/StackScripts/StackScriptsDetail.tsx
@@ -13,6 +13,7 @@ import {
useUpdateStackScriptMutation,
} from 'src/queries/stackscripts';
+import { getRestrictedResourceText } from '../Account/utils';
import {
canUserModifyAccountStackScript,
getStackScriptUrl,
@@ -94,6 +95,13 @@ export const StackScriptDetail = () => {
: undefined,
pathname: location.pathname,
}}
+ buttonDataAttrs={{
+ tooltipText: getRestrictedResourceText({
+ action: 'create',
+ isSingular: false,
+ resourceType: 'Linodes',
+ }),
+ }}
createButtonText="Deploy New Linode"
disabledCreateButton={userCannotAddLinodes}
docsLabel="Docs"
diff --git a/packages/manager/src/features/StackScripts/StackScriptsLanding.test.tsx b/packages/manager/src/features/StackScripts/StackScriptsLanding.test.tsx
deleted file mode 100644
index e5cffbb88e0..00000000000
--- a/packages/manager/src/features/StackScripts/StackScriptsLanding.test.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { render } from '@testing-library/react';
-import * as React from 'react';
-
-import { wrapWithTheme } from 'src/utilities/testHelpers';
-
-import { StackScriptsLanding } from './StackScriptsLanding';
-
-vi.mock('@linode/api-v4/lib/account', async () => {
- const actual = await vi.importActual('@linode/api-v4/lib/account');
- return {
- ...actual,
- getUsers: vi.fn().mockResolvedValue({}),
- };
-});
-
-describe('StackScripts Landing', () => {
- const { getByText } = render(wrapWithTheme());
-
- it('icon text link text should read "Create StackScript"', () => {
- getByText(/create stackscript/i);
- });
-});
diff --git a/packages/manager/src/features/StackScripts/StackScriptsLanding.tsx b/packages/manager/src/features/StackScripts/StackScriptsLanding.tsx
deleted file mode 100644
index 6db39c7373d..00000000000
--- a/packages/manager/src/features/StackScripts/StackScriptsLanding.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import { CircleProgress, Notice } from '@linode/ui';
-import Grid from '@mui/material/Unstable_Grid2';
-import { createLazyRoute } from '@tanstack/react-router';
-import * as React from 'react';
-import { useHistory } from 'react-router-dom';
-
-import { DocumentTitleSegment } from 'src/components/DocumentTitle';
-import { LandingHeader } from 'src/components/LandingHeader';
-import { listToItemsByID } from 'src/queries/base';
-import { useAllImagesQuery } from 'src/queries/images';
-
-import StackScriptPanel from './StackScriptPanel/StackScriptPanel';
-
-import type { Image } from '@linode/api-v4';
-
-export const StackScriptsLanding = () => {
- const history = useHistory<{
- successMessage?: string;
- }>();
-
- const { data: _imagesData, isLoading: _loading } = useAllImagesQuery(
- {},
- { is_public: true }
- );
-
- const imagesData: Record = listToItemsByID(_imagesData ?? []);
-
- const goToCreateStackScript = () => {
- history.push('/stackscripts/create');
- };
-
- return (
-
-
- {!!history.location.state && !!history.location.state.successMessage ? (
-
- ) : null}
-
-
- {_loading ? (
-
- ) : (
-
-
-
- )}
-
-
- );
-};
-
-export default StackScriptsLanding;
-
-export const stackScriptsLandingLazyRoute = createLazyRoute('/stackscripts')({
- component: StackScriptsLanding,
-});
diff --git a/packages/manager/src/features/StackScripts/stackScriptUtils.test.ts b/packages/manager/src/features/StackScripts/stackScriptUtils.test.ts
index a5ed3b08dba..afc729bc96b 100644
--- a/packages/manager/src/features/StackScripts/stackScriptUtils.test.ts
+++ b/packages/manager/src/features/StackScripts/stackScriptUtils.test.ts
@@ -1,6 +1,9 @@
-import { Grant } from '@linode/api-v4/lib/account';
+import {
+ canUserModifyAccountStackScript,
+ getStackScriptImages,
+} from './stackScriptUtils';
-import { canUserModifyAccountStackScript } from './stackScriptUtils';
+import type { Grant } from '@linode/api-v4';
describe('canUserModifyStackScript', () => {
let isRestrictedUser = false;
@@ -51,3 +54,19 @@ describe('canUserModifyStackScript', () => {
).toBe(false);
});
});
+
+describe('getStackScriptImages', () => {
+ it('removes the linode/ prefix from Image IDs', () => {
+ const images = ['linode/ubuntu20.04', 'linode/debian9'];
+ expect(getStackScriptImages(images)).toBe('ubuntu20.04, debian9');
+ });
+ it('removes the handles images without the linode/ prefix', () => {
+ const images = ['ubuntu20.04', 'linode/debian9'];
+ expect(getStackScriptImages(images)).toBe('ubuntu20.04, debian9');
+ });
+ it('gracefully handles a null image', () => {
+ const images = ['linode/ubuntu20.04', null, 'linode/debian9'];
+ // @ts-expect-error intentionally testing invalid value because API is known to return null
+ expect(getStackScriptImages(images)).toBe('ubuntu20.04, debian9');
+ });
+});
diff --git a/packages/manager/src/features/StackScripts/stackScriptUtils.ts b/packages/manager/src/features/StackScripts/stackScriptUtils.ts
index 4b51d959d2d..1b2d2f67db3 100644
--- a/packages/manager/src/features/StackScripts/stackScriptUtils.ts
+++ b/packages/manager/src/features/StackScripts/stackScriptUtils.ts
@@ -36,16 +36,6 @@ const oneClickFilter = [
export const getOneClickApps = (params?: Params) =>
getStackScripts(params, oneClickFilter);
-export const getStackScriptsByUser: StackScriptsRequest = (
- username: string,
- params?: Params,
- filter?: Filter
-) =>
- getStackScripts(params, {
- ...filter,
- username,
- });
-
export const getMineAndAccountStackScripts: StackScriptsRequest = (
params?: Params,
filter?: Filter
@@ -158,6 +148,42 @@ export const canUserModifyAccountStackScript = (
return grantsForThisStackScript.permissions === 'read_write';
};
+/**
+ * Gets a comma separated string of Image IDs to display to the user
+ * with the linode/ prefix removed from the Image IDs
+ */
+export const getStackScriptImages = (images: StackScript['images']) => {
+ const cleanedImages: string[] = [];
+
+ for (const image of images) {
+ if (image === 'any/all') {
+ return 'Any/All';
+ }
+
+ if (!image) {
+ // Sometimes the API returns `null` in the images array 😳
+ continue;
+ }
+
+ if (image.startsWith('linode/')) {
+ cleanedImages.push(image.split('linode/')[1]);
+ } else {
+ cleanedImages.push(image);
+ }
+ }
+
+ return cleanedImages.join(', ');
+};
+/**
+ * Determines if a StackScript is a StackScript created by LKE.
+ *
+ * This function exists because the API returns these but we try
+ * to hide these StackScripts from the user in the UI.
+ */
+export const isLKEStackScript = (stackscript: StackScript) => {
+ return stackscript.username.startsWith('lke-service-account-');
+};
+
export const stackscriptFieldNameOverrides: Partial<
Record
> = {
diff --git a/packages/manager/src/features/StackScripts/types.ts b/packages/manager/src/features/StackScripts/types.ts
index c68f0168804..12c7bb55742 100644
--- a/packages/manager/src/features/StackScripts/types.ts
+++ b/packages/manager/src/features/StackScripts/types.ts
@@ -1,5 +1,4 @@
-import { StackScript } from '@linode/api-v4/lib/stackscripts';
-import { ResourcePage } from '@linode/api-v4/lib/types';
+import type { ResourcePage, StackScript } from '@linode/api-v4';
export type StackScriptsRequest = (
params?: unknown,
diff --git a/packages/manager/src/queries/stackscripts.ts b/packages/manager/src/queries/stackscripts.ts
index c950ce222fb..055a5bafa37 100644
--- a/packages/manager/src/queries/stackscripts.ts
+++ b/packages/manager/src/queries/stackscripts.ts
@@ -1,11 +1,13 @@
import {
createStackScript,
+ deleteStackScript,
getStackScript,
getStackScripts,
updateStackScript,
} from '@linode/api-v4';
import { createQueryKeys } from '@lukemorales/query-key-factory';
import {
+ keepPreviousData,
useInfiniteQuery,
useMutation,
useQuery,
@@ -25,6 +27,7 @@ import type {
StackScript,
StackScriptPayload,
} from '@linode/api-v4';
+import type { UseMutationOptions } from '@tanstack/react-query';
import type { EventHandlerData } from 'src/hooks/useEventHandlers';
export const getAllOCAsRequest = (passedParams: Params = {}) =>
@@ -79,23 +82,6 @@ export const useCreateStackScriptMutation = () => {
});
};
-export const useUpdateStackScriptMutation = (id: number) => {
- const queryClient = useQueryClient();
-
- return useMutation>({
- mutationFn: (data) => updateStackScript(id, data),
- onSuccess(stackscript) {
- queryClient.setQueryData(
- stackscriptQueries.stackscript(stackscript.id).queryKey,
- stackscript
- );
- queryClient.invalidateQueries({
- queryKey: stackscriptQueries.infinite._def,
- });
- },
- });
-};
-
export const useStackScriptsInfiniteQuery = (
filter: Filter = {},
enabled = true
@@ -110,8 +96,60 @@ export const useStackScriptsInfiniteQuery = (
return page + 1;
},
initialPageParam: 1,
+ placeholderData: keepPreviousData,
});
+export const useUpdateStackScriptMutation = (
+ id: number,
+ options?: UseMutationOptions<
+ StackScript,
+ APIError[],
+ Partial
+ >
+) => {
+ const queryClient = useQueryClient();
+
+ return useMutation>({
+ mutationFn: (data) => updateStackScript(id, data),
+ ...options,
+ onSuccess(stackscript, vars, ctx) {
+ queryClient.invalidateQueries({
+ queryKey: stackscriptQueries.infinite._def,
+ });
+ queryClient.setQueryData(
+ stackscriptQueries.stackscript(id).queryKey,
+ stackscript
+ );
+ if (options?.onSuccess) {
+ options.onSuccess(stackscript, vars, ctx);
+ }
+ },
+ });
+};
+
+export const useDeleteStackScriptMutation = (
+ id: number,
+ options: UseMutationOptions<{}, APIError[]>
+) => {
+ const queryClient = useQueryClient();
+
+ return useMutation<{}, APIError[]>({
+ mutationFn: () => deleteStackScript(id),
+ ...options,
+ onSuccess(...params) {
+ queryClient.invalidateQueries({
+ queryKey: stackscriptQueries.infinite._def,
+ });
+ queryClient.removeQueries({
+ queryKey: stackscriptQueries.stackscript(id).queryKey,
+ });
+ if (options.onSuccess) {
+ options.onSuccess(...params);
+ }
+ },
+ });
+};
+
export const stackScriptEventHandler = ({
event,
invalidateQueries,
diff --git a/packages/manager/src/routes/stackscripts/index.tsx b/packages/manager/src/routes/stackscripts/index.tsx
index d8483673dc1..d9a3a289532 100644
--- a/packages/manager/src/routes/stackscripts/index.tsx
+++ b/packages/manager/src/routes/stackscripts/index.tsx
@@ -17,27 +17,27 @@ const stackScriptsLandingRoute = createRoute({
getParentRoute: () => stackScriptsRoute,
path: '/',
}).lazy(() =>
- import('src/features/StackScripts/StackScriptsLanding').then(
- (m) => m.stackScriptsLandingLazyRoute
- )
+ import(
+ 'src/features/StackScripts/StackScriptLanding/StackScriptsLanding'
+ ).then((m) => m.stackScriptsLandingLazyRoute)
);
const stackScriptsAccountRoute = createRoute({
getParentRoute: () => stackScriptsRoute,
path: 'account',
}).lazy(() =>
- import('src/features/StackScripts/StackScriptsLanding').then(
- (m) => m.stackScriptsLandingLazyRoute
- )
+ import(
+ 'src/features/StackScripts/StackScriptLanding/StackScriptsLanding'
+ ).then((m) => m.stackScriptsLandingLazyRoute)
);
const stackScriptsCommunityRoute = createRoute({
getParentRoute: () => stackScriptsRoute,
path: 'community',
}).lazy(() =>
- import('src/features/StackScripts/StackScriptsLanding').then(
- (m) => m.stackScriptsLandingLazyRoute
- )
+ import(
+ 'src/features/StackScripts/StackScriptLanding/StackScriptsLanding'
+ ).then((m) => m.stackScriptsLandingLazyRoute)
);
const stackScriptsCreateRoute = createRoute({