Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8680aae
Added missing padding around the Managed dashboard card
fabrice-akamai Sep 26, 2025
d53e3f6
changed spacing to spacingFunction
fabrice-akamai Sep 26, 2025
ef5485f
Merge branch 'develop' of https://github.com/fabrice-akamai/manager i…
fabrice-akamai Mar 2, 2026
2a8188d
Merge branch 'develop' of https://github.com/fabrice-akamai/manager i…
fabrice-akamai Mar 3, 2026
2db02f3
Merge branch 'develop' of https://github.com/fabrice-akamai/manager i…
fabrice-akamai Mar 10, 2026
a8323a1
Merge branch 'develop' of https://github.com/fabrice-akamai/manager i…
fabrice-akamai Mar 19, 2026
77ec16e
Merge branch 'develop' of https://github.com/fabrice-akamai/manager i…
fabrice-akamai Mar 25, 2026
4a803c8
Merge branch 'develop' of https://github.com/fabrice-akamai/manager i…
fabrice-akamai Mar 27, 2026
408d904
Implement basic sharegroup create form
fabrice-akamai Mar 31, 2026
1befb73
Added changeset: Implement the basic share group create page
fabrice-akamai Mar 31, 2026
2c3f4dc
update unit test
fabrice-akamai Mar 31, 2026
66a034c
Merge branch 'develop' into UIE-9410-create-share-group-page
fabrice-akamai Mar 31, 2026
007e94d
Update packages/manager/.changeset/pr-13550-upcoming-features-1774974…
fabrice-akamai Mar 31, 2026
2dbdb37
Update the createShareGroup query
fabrice-akamai Mar 31, 2026
664d8c3
Merge branch 'UIE-9410-create-share-group-page' of https://github.com…
fabrice-akamai Mar 31, 2026
aabb130
Update description cell to truncate the text overflow and use tooltips
fabrice-akamai Apr 1, 2026
f099e81
Update table design and tooltip appearance
fabrice-akamai Apr 2, 2026
e23162c
Update table cell styling
fabrice-akamai Apr 2, 2026
15f2a0e
Update the share groups table columns and add tooltips
fabrice-akamai Apr 6, 2026
1f3b935
Update description column width
fabrice-akamai Apr 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Implement the basic share group create page ([#13550](https://github.com/linode/manager/pull/13550))
Comment thread
fabrice-akamai marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;
let mockMutateAsync: ReturnType<typeof vi.fn>;

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(<ShareGroupsCreate />);

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(<ShareGroupsCreate />);

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(<ShareGroupsCreate />);

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(<ShareGroupsCreate />);

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();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { useCreateShareGroupMutation } from '@linode/queries';
import {
Box,
Button,
Divider,
Notice,
Paper,
Stack,
TextField,
Typography,
} from '@linode/ui';
import { useNavigate } from '@tanstack/react-router';
import { useSnackbar } from 'notistack';
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 { enqueueSnackbar } = useSnackbar();

const { control, handleSubmit, setError } =
useForm<CreateSharegroupPayload>();

const selectedImages = [];

const onSubmit = handleSubmit(async (values) => {
try {
await createShareGroup(values);

enqueueSnackbar('Sharegroup scheduled for creation', {
variant: 'info',
});
Comment thread
fabrice-akamai marked this conversation as resolved.
Outdated

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 (
<form onSubmit={onSubmit}>
<Paper>
<Stack spacing={2}>
<Typography variant="h2">Share group details</Typography>
<Typography variant="body1">
Add a name and description for your share group. These details are
visible to all group members.
</Typography>
<Controller
control={control}
name="label"
render={({ field, fieldState }) => (
<TextField
label="Label"
noMarginTop
required
{...field}
errorText={fieldState.error?.message}
onChange={(e) =>
field.onChange(
e.target.value === '' ? undefined : e.target.value
)
}
value={field.value ?? ''}
/>
)}
/>
<Controller
control={control}
name="description"
render={({ field, fieldState }) => (
<TextField
errorText={fieldState.error?.message}
label="Description"
multiline
noMarginTop
{...field}
onChange={(e) =>
field.onChange(
e.target.value === '' ? undefined : e.target.value
)
}
rows={1}
value={field.value ?? ''}
/>
)}
/>
Comment on lines +74 to +93
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's check with the API team on implementing a maximum number of characters for the description field

Image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I'll reach out to them about this.

</Stack>
<Divider sx={{ marginTop: 4, marginBottom: 4 }} />
<Stack spacing={2}>
<Typography variant="h2">Images</Typography>
<Notice variant="info">Images table is coming soon...</Notice>
</Stack>
<Divider sx={{ marginTop: 4, marginBottom: 4 }} />
<Stack spacing={2}>
<Typography variant="h2">
Selected images ({selectedImages.length})
</Typography>
<Notice variant="info">Selected images is coming soon...</Notice>
</Stack>
</Paper>
<Box display="flex" flexWrap="wrap" justifyContent="flex-end" mt={2}>
<Button buttonType="primary" type="submit">
Create Share Group
</Button>
</Box>
</form>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Grid from '@mui/material/Grid';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's no do straight imports from @mui

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there an alternative you suggest @abailly-akamai? We do this throughout the codebase and I don't believe we have a Grid in the ui package

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€• My bad old habit when I see a MUI import. It's one of the very few component we haven't abstracted. Carry on!

oof-the-simpsons

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haha, no worries. Thanks for clarifying this @dwiley-akamai @abailly-akamai!

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 (
<>
<DocumentTitleSegment segment="Create a Share Group" />
<LandingHeader
docsLabel="Docs"
docsLink="https://techdocs.akamai.com/cloud-computing/docs/image-sharing"
spacingBottom={4}
title="Create"
/>
<Grid size={12}>
<ShareGroupsCreate />
</Grid>
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createLazyRoute } from '@tanstack/react-router';

import { ShareGroupsCreateContainer } from './ShareGroupsCreateContainer';

export const shareGroupsCreateLazyRoute = createLazyRoute(
'/images/share-groups/create'
)({
component: ShareGroupsCreateContainer,
});
10 changes: 10 additions & 0 deletions packages/manager/src/routes/images/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -324,6 +333,7 @@ export const imagesRouteTree = imagesRoute.addChildren([
]),
shareGroupsLandingRoute.addChildren([
shareGroupsIndexRoute.addChildren([shareGroupsTypeRoute]),
shareGroupsCreateRoute,
]),
imagesCreateRoute.addChildren([
imagesCreateIndexRoute,
Expand Down
27 changes: 26 additions & 1 deletion packages/queries/src/images/sharegroups.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -95,3 +102,21 @@ export const useShareGroupsInfiniteQuery = (
initialPageParam: 1,
retry: false,
});

export const useCreateShareGroupMutation = () => {
const queryclient = useQueryClient();

return useMutation<Sharegroup, APIError[], CreateSharegroupPayload>({
mutationFn: createSharegroup,
onSuccess(shareGroup) {
queryclient.invalidateQueries({
queryKey: shareGroupsQueries.sharegroups._ctx.paginated._def,
});
Comment thread
fabrice-akamai marked this conversation as resolved.
queryclient.setQueryData<Sharegroup>(
shareGroupsQueries.sharegroups._ctx.sharegroup(shareGroup.id.toString())
.queryKey,
shareGroup,
);
},
});
};
Loading