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