diff --git a/packages/manager/.changeset/pr-13550-upcoming-features-1774974481770.md b/packages/manager/.changeset/pr-13550-upcoming-features-1774974481770.md new file mode 100644 index 00000000000..0dee27b3bb8 --- /dev/null +++ b/packages/manager/.changeset/pr-13550-upcoming-features-1774974481770.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Private Image Sharing: Implement basic structure of Share Group Create page ([#13550](https://github.com/linode/manager/pull/13550)) diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImagesTable.styles.ts b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImagesTable.styles.ts index c61583d2128..8f4eea5e6aa 100644 --- a/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImagesTable.styles.ts +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImagesTable.styles.ts @@ -37,4 +37,8 @@ export const StyledImageTableContainer = styled(Box, { '& cds-table-row:last-child:not([rowborder])': { borderBottom: `1px solid ${theme.tokens.component.Table.Row.Border}`, }, + + '& cds-table-header-cell, & cds-table-cell': { + boxSizing: 'border-box', + }, })); diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupRow.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupRow.tsx index 6ac9015479d..b99743e2b5d 100644 --- a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupRow.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupRow.tsx @@ -1,5 +1,6 @@ import { usePreferences, useProfile } from '@linode/queries'; -import { Hidden, LinkButton } from '@linode/ui'; +import { Hidden, LinkButton, Tooltip } from '@linode/ui'; +import { truncateEnd } from '@linode/utilities'; import { TableCell, TableRow } from 'akamai-cds-react-components/Table'; import React from 'react'; @@ -42,18 +43,38 @@ export const ShareGroupRow = (props: Props) => { data-qa-sharegroup-row={id} key={id} rowborder={!isTableStripingEnabled} + style={{ padding: 0 }} zebra={isTableStripingEnabled} > - - {}}>{label} - - {description} - {members_count} + 32 ? label : ''}> + + {}} + sx={{ + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', + display: 'block', + }} + > + {truncateEnd(label, 32)} + + + + 50 ? description : ''}> + + {truncateEnd(description, 50)} + + + {members_count} - {images_count} + {images_count} - + {created && formatDate(created, { timezone: profile?.timezone, @@ -61,7 +82,7 @@ export const ShareGroupRow = (props: Props) => { - + {updated !== null ? formatDate(updated, { timezone: profile?.timezone }) : '–'} diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupTable.styles.ts b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupTable.styles.ts index 947a50512ea..154dd1d947e 100644 --- a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupTable.styles.ts +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupTable.styles.ts @@ -7,7 +7,7 @@ export const StyledActionMenuWrapper = styled(TableCell, { justifyContent: 'flex-end', display: 'flex', alignItems: 'center', - maxWidth: 40, + maxWidth: '5%', '& button': { padding: 0, color: theme.tokens.alias.Content.Icon.Primary.Default, @@ -18,3 +18,61 @@ export const StyledActionMenuWrapper = styled(TableCell, { color: theme.tokens.alias.Content.Icon.Primary.Hover, }, })); + +const TABLE_CELL_BASE_STYLES: React.CSSProperties = { + boxSizing: 'border-box', +}; + +export const StyledShareGroupsTableContainer = styled('div', { + label: 'StyledShareGroupsTable', +})(({ theme }) => ({ + '& .group-column': { + minWidth: '20%', + ...TABLE_CELL_BASE_STYLES, + [theme.breakpoints.down('sm')]: { + minWidth: '30%', + }, + }, + '& .description-column': { + minWidth: '25%', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', + display: 'block', + ...TABLE_CELL_BASE_STYLES, + [theme.breakpoints.down('lg')]: { + minWidth: '40%', + }, + [theme.breakpoints.down('sm')]: { + minWidth: '40%', + }, + }, + '& .membersCount-column': { + minWidth: '11%', + ...TABLE_CELL_BASE_STYLES, + [theme.breakpoints.down('lg')]: { + minWidth: '15%', + }, + }, + '& .imagesCount-column': { + minWidth: '9%', + ...TABLE_CELL_BASE_STYLES, + [theme.breakpoints.down('lg')]: { + minWidth: '15%', + }, + }, + '& .created-column': { + minWidth: '15%', + ...TABLE_CELL_BASE_STYLES, + whiteSpace: 'nowrap', + }, + '& .updated-column': { + minWidth: '15%', + ...TABLE_CELL_BASE_STYLES, + whiteSpace: 'nowrap', + }, + '& .action-column': { + maxWidth: '5%', + ...TABLE_CELL_BASE_STYLES, + }, +})); diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreate.test.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreate.test.tsx new file mode 100644 index 00000000000..88df9069ac9 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreate.test.tsx @@ -0,0 +1,143 @@ +import { imageSharegroupFactory } from '@linode/utilities'; +import { userEvent } from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ShareGroupsCreate } from './ShareGroupsCreate'; + +const queryMocks = vi.hoisted(() => ({ + useCreateShareGroupMutation: vi.fn().mockReturnValue({}), + useNavigate: vi.fn().mockReturnValue(vi.fn()), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useCreateShareGroupMutation: queryMocks.useCreateShareGroupMutation, + }; +}); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useNavigate: queryMocks.useNavigate, + }; +}); + +describe('ShareGroupsCreate', () => { + const shareGroupLabel = 'My Share Group'; + const shareGroupDescription = 'Test Description'; + + let mockNavigate: ReturnType; + let mockMutateAsync: ReturnType; + + beforeEach(() => { + mockNavigate = vi.fn(); + mockMutateAsync = vi.fn(); + + queryMocks.useNavigate.mockReturnValue(mockNavigate); + queryMocks.useCreateShareGroupMutation.mockReturnValue({ + mutateAsync: mockMutateAsync, + isLoading: false, + error: null, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should render the form with all fields, titles, and buttons in their default state', () => { + const { getByRole, getByText } = renderWithTheme(); + + expect(getByText('Share group details')).toBeVisible(); + expect(getByText('Images')).toBeVisible(); + expect(getByText('Selected images (0)')).toBeVisible(); + + expect( + getByText( + 'Add a name and description for your share group. These details are visible to all group members.' + ) + ).toBeVisible(); + + const labelField = getByRole('textbox', { name: /Label/i }); + expect(labelField).toBeVisible(); + expect(labelField).toHaveValue(''); + + const descriptionField = getByRole('textbox', { name: /Description/i }); + expect(descriptionField).toBeVisible(); + expect(descriptionField).toHaveValue(''); + + const submitButton = getByRole('button', { name: /Create Share Group/i }); + expect(submitButton).toBeVisible(); + expect(submitButton).toBeEnabled(); + }); + + it('should submit the form with valid data', async () => { + const shareGroup = imageSharegroupFactory.build(); + + mockMutateAsync.mockResolvedValue(shareGroup); + + const { getByRole } = renderWithTheme(); + + const labelField = getByRole('textbox', { name: /Label/i }); + const descriptionField = getByRole('textbox', { name: /Description/i }); + const submitButton = getByRole('button', { name: /Create Share Group/i }); + + await userEvent.type(labelField, shareGroupLabel); + await userEvent.type(descriptionField, shareGroupDescription); + await userEvent.click(submitButton); + + expect(mockMutateAsync).toHaveBeenCalledWith({ + label: shareGroupLabel, + description: shareGroupDescription, + }); + + expect(mockNavigate).toHaveBeenCalledWith({ + search: expect.any(Function), + to: '/images/share-groups', + }); + }); + + it('should submit the form with only label (description is optional)', async () => { + const shareGroup = imageSharegroupFactory.build(); + + mockMutateAsync.mockResolvedValue(shareGroup); + + const { getByRole } = renderWithTheme(); + + const labelField = getByRole('textbox', { name: /Label/i }); + const submitButton = getByRole('button', { name: /Create Share Group/i }); + + await userEvent.type(labelField, shareGroupLabel); + await userEvent.click(submitButton); + + expect(mockMutateAsync).toHaveBeenCalledWith({ + label: shareGroupLabel, + }); + }); + + it('should display field-specific errors from API', async () => { + const apiError = [ + { + field: 'label', + reason: 'Label must be unique', + }, + ]; + + mockMutateAsync.mockRejectedValue(apiError); + + const { getByRole, getByText } = renderWithTheme(); + + const labelField = getByRole('textbox', { name: /Label/i }); + const submitButton = getByRole('button', { name: /Create Share Group/i }); + + await userEvent.type(labelField, 'Duplicate Label'); + await userEvent.click(submitButton); + + expect(getByText('Label must be unique')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreate.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreate.tsx new file mode 100644 index 00000000000..1a0fef28bec --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreate.tsx @@ -0,0 +1,115 @@ +import { useCreateShareGroupMutation } from '@linode/queries'; +import { + Box, + Button, + Divider, + Notice, + Paper, + Stack, + TextField, + Typography, +} from '@linode/ui'; +import { useNavigate } from '@tanstack/react-router'; +import * as React from 'react'; +import { Controller, useForm } from 'react-hook-form'; + +import type { CreateSharegroupPayload } from '@linode/api-v4'; + +export const ShareGroupsCreate = () => { + const navigate = useNavigate(); + + const { mutateAsync: createShareGroup } = useCreateShareGroupMutation(); + + const { control, handleSubmit, setError } = + useForm(); + + const selectedImages = []; + + const onSubmit = handleSubmit(async (values) => { + try { + await createShareGroup(values); + + navigate({ + search: () => ({}), + to: '/images/share-groups', + }); + } catch (errors) { + for (const error of errors) { + if (error.field) { + setError(error.field, { message: error.reason }); + } else { + setError('root', { message: error.reason }); + } + } + } + }); + return ( +
+ + + Share group details + + Add a name and description for your share group. These details are + visible to all group members. + + ( + + field.onChange( + e.target.value === '' ? undefined : e.target.value + ) + } + value={field.value ?? ''} + /> + )} + /> + ( + + field.onChange( + e.target.value === '' ? undefined : e.target.value + ) + } + rows={1} + value={field.value ?? ''} + /> + )} + /> + + + + Images + Images table is coming soon... + + + + + Selected images ({selectedImages.length}) + + Selected images is coming soon... + + + + + +
+ ); +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreateContainer.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreateContainer.tsx new file mode 100644 index 00000000000..2c298112a3b --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreateContainer.tsx @@ -0,0 +1,24 @@ +import Grid from '@mui/material/Grid'; +import * as React from 'react'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle/DocumentTitle'; +import { LandingHeader } from 'src/components/LandingHeader/LandingHeader'; + +import { ShareGroupsCreate } from './ShareGroupsCreate'; + +export const ShareGroupsCreateContainer = () => { + return ( + <> + + + + + + + ); +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreateLazyRoute.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreateLazyRoute.tsx new file mode 100644 index 00000000000..5cd1ac095fe --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreateLazyRoute.tsx @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { ShareGroupsCreateContainer } from './ShareGroupsCreateContainer'; + +export const shareGroupsCreateLazyRoute = createLazyRoute( + '/images/share-groups/create' +)({ + component: ShareGroupsCreateContainer, +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsTable.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsTable.tsx index 8aaf6da3bb4..b6548f9ba01 100644 --- a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsTable.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsTable.tsx @@ -28,6 +28,7 @@ import { StyledImageTableSubheader, } from '../ImageLibrary/ImagesTable.styles'; import { ShareGroupRow } from './ShareGroupRow'; +import { StyledShareGroupsTableContainer } from './ShareGroupTable.styles'; import type { ShareGroupsViewTableColConfig } from './shareGroupsTabsConfig'; import type { APIError, Sharegroup } from '@linode/api-v4'; @@ -132,93 +133,100 @@ export const ShareGroupsTable = (props: ShareGroupsTableProps) => { )} - - - - {columns.map((col, idx) => { - const cell = col.sortableProps ? ( - - handleOrderChange( - col.sortableProps?.label ?? col.name, - order === 'asc' ? 'desc' : 'asc' - ) - } - sortable - sorted={ - orderBy === col.sortableProps?.label ? order : undefined - } - style={{ ...col.style }} - > - {col.name} - - ) : ( - - {col.name} - - ); + +
+ + + {columns.map((col, idx) => { + const cell = col.sortableProps ? ( + + handleOrderChange( + col.sortableProps?.label ?? col.name, + order === 'asc' ? 'desc' : 'asc' + ) + } + sortable + sorted={ + orderBy === col.sortableProps?.label ? order : undefined + } + style={{ ...col.style }} + > + {col.name} + + ) : ( + + {col.name} + + ); - return col.hidden ? ( - - {cell} - - ) : ( - cell - ); - })} - - - - - {!error && shareGroups.length === 0 && ( - - - ({ + return col.hidden ? ( + + {cell} + + ) : ( + cell + ); + })} + + + + + {!error && shareGroups.length === 0 && ( + + - - {emptyMessage.main} - {!query && emptyMessage.instruction && ( - - {emptyMessage.instruction} - - )} - - - - )} - {error && query && ( - - - - - - )} + ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: theme.spacingFunction(4), + p: `${theme.spacingFunction(24)} ${theme.spacingFunction(32)}`, + width: '100%', + })} + > + + {emptyMessage.main} + {!query && emptyMessage.instruction && ( + + {emptyMessage.instruction} + + )} + + + + )} + {error && query && ( + + + + + + )} - {shareGroups.map((sharegroup) => ( - - ))} - -
+ {shareGroups.map((sharegroup) => ( + + ))} + + + {pagination.count > DEFAULT_PAGE_SIZES[0] && ( ; /* Column name */ name: string; - /* Provide sortableProps to enable sorting for this column. */ sortableProps?: { /* API field used for sorting this column */ @@ -60,29 +62,36 @@ export const shareGroupsSubTabs: ImageSubTab[] = [ ]; const OWNED_GROUPS_TABLE_COLUMNS: ShareGroupsViewTableColConfig[] = [ - { name: 'Group', sortableProps: { label: 'label' } }, + { + name: 'Group', + sortableProps: { label: 'label' }, + className: 'group-column', + }, { name: 'Description', sortableProps: { label: 'description' }, + className: 'description-column', }, { name: '# of members', + className: 'membersCount-column', }, { name: '# of images', hidden: 'smDown', + className: 'imagesCount-column', }, { name: 'Created', sortableProps: { label: 'created' }, hidden: 'lgDown', - style: { whiteSpace: 'nowrap' }, + className: 'created-column', }, { name: 'Updated', sortableProps: { label: 'updated' }, hidden: 'lgDown', - style: { whiteSpace: 'nowrap' }, + className: 'updated-column', }, ]; @@ -127,7 +136,7 @@ export const SHAREGROUPS_CONFIG: Record< }, columns: OWNED_GROUPS_TABLE_COLUMNS, emptyMessage: { - main: 'No Share groups to display', + main: 'No share groups to display', instruction: 'Click \u2018Create Share Group\u2019 to create your first share group and share your custom images with other accounts.', }, diff --git a/packages/manager/src/routes/images/index.ts b/packages/manager/src/routes/images/index.ts index 0e48c5b2c2a..0d566188a0f 100644 --- a/packages/manager/src/routes/images/index.ts +++ b/packages/manager/src/routes/images/index.ts @@ -315,6 +315,15 @@ const shareGroupsTypeRoute = createRoute({ validateSearch: (search: ImagesSearchParams) => search, }); +const shareGroupsCreateRoute = createRoute({ + getParentRoute: () => imagesRoute, + path: 'share-groups/create', +}).lazy(() => + import( + 'src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsCreate/ShareGroupsCreateLazyRoute' + ).then((m) => m.shareGroupsCreateLazyRoute) +); + export const imagesRouteTree = imagesRoute.addChildren([ imagesIndexRoute.addChildren([imageActionRoute]), imageLibraryLandingRoute.addChildren([ @@ -324,6 +333,7 @@ export const imagesRouteTree = imagesRoute.addChildren([ ]), shareGroupsLandingRoute.addChildren([ shareGroupsIndexRoute.addChildren([shareGroupsTypeRoute]), + shareGroupsCreateRoute, ]), imagesCreateRoute.addChildren([ imagesCreateIndexRoute, diff --git a/packages/queries/src/images/sharegroups.ts b/packages/queries/src/images/sharegroups.ts index e03fc06eb1c..3191acdf9df 100644 --- a/packages/queries/src/images/sharegroups.ts +++ b/packages/queries/src/images/sharegroups.ts @@ -1,14 +1,21 @@ -import { getSharegroup, getSharegroups } from '@linode/api-v4'; +import { + createSharegroup, + getSharegroup, + getSharegroups, +} from '@linode/api-v4'; import { getAll } from '@linode/utilities'; import { createQueryKeys } from '@lukemorales/query-key-factory'; import { keepPreviousData, useInfiniteQuery, + useMutation, useQuery, + useQueryClient, } from '@tanstack/react-query'; import type { APIError, + CreateSharegroupPayload, Filter, Params, ResourcePage, @@ -95,3 +102,27 @@ export const useShareGroupsInfiniteQuery = ( initialPageParam: 1, retry: false, }); + +export const useCreateShareGroupMutation = () => { + const queryclient = useQueryClient(); + + return useMutation({ + mutationFn: createSharegroup, + onSuccess(shareGroup) { + queryclient.invalidateQueries({ + queryKey: shareGroupsQueries.sharegroups._ctx.paginated._def, + }); + queryclient.invalidateQueries({ + queryKey: shareGroupsQueries.sharegroups._ctx.all._def, + }); + queryclient.invalidateQueries({ + queryKey: shareGroupsQueries.sharegroups._ctx.infinite._def, + }); + queryclient.setQueryData( + shareGroupsQueries.sharegroups._ctx.sharegroup(shareGroup.id.toString()) + .queryKey, + shareGroup, + ); + }, + }); +};