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 (
+
+ );
+};
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,
+ );
+ },
+ });
+};