Skip to content

Commit

Permalink
feat: [M3-8103] - Refactor Image Create and Add Tags (#10471)
Browse files Browse the repository at this point in the history
* initial work

* add helper text

* make the restricted user experience better

* fixes and clean up

* fix type errors

* add missing placeholder text

* improve e2e testing for image creation

* add changesets

* improve api-v4 docs

---------

Co-authored-by: Banks Nussman <banks@nussman.us>
  • Loading branch information
bnussman-akamai and bnussman authored May 17, 2024
1 parent 7b89452 commit 4b4a3da
Show file tree
Hide file tree
Showing 17 changed files with 541 additions and 439 deletions.
21 changes: 2 additions & 19 deletions packages/api-v4/src/images/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import Request, {
setXFilter,
} from '../request';
import { Filter, Params, ResourcePage as Page } from '../types';
import { Image, ImageUploadPayload, UploadImageResponse } from './types';
import { CreateImagePayload, Image, ImageUploadPayload, UploadImageResponse } from './types';

/**
* Get information about a single Image.
Expand All @@ -39,25 +39,8 @@ export const getImages = (params: Params = {}, filters: Filter = {}) =>

/**
* Create a private gold-master Image from a Linode Disk.
*
* @param diskId { number } The ID of the Linode Disk that this Image will be created from.
* @param label { string } A short description of the Image. Labels cannot contain special characters.
* @param description { string } A detailed description of this Image.
* @param cloud_init { boolean } An indicator of whether Image supports cloud-init.
*/
export const createImage = (
diskId: number,
label?: string,
description?: string,
cloud_init?: boolean
) => {
const data = {
disk_id: diskId,
...(label && { label }),
...(description && { description }),
...(cloud_init && { cloud_init }),
};

export const createImage = (data: CreateImagePayload) => {
return Request<Image>(
setURL(`${API_ROOT}/images`),
setMethod('POST'),
Expand Down
21 changes: 20 additions & 1 deletion packages/api-v4/src/images/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,32 @@ export interface UploadImageResponse {
}

export interface BaseImagePayload {
/**
* A short title of this Image.
*
* Defaults to the label of the Disk it is being created from if not provided.
*/
label?: string;
/**
* A detailed description of this Image.
*/
description?: string;
/**
* Whether this Image supports cloud-init.
* @default false
*/
cloud_init?: boolean;
/**
* An array of Tags applied to this Image. Tags are for organizational purposes only.
*/
tags?: string[];
}

export interface CreateImagePayload extends BaseImagePayload {
diskID: number;
/**
* The ID of the Linode Disk that this Image will be created from.
*/
disk_id: number;
}

export interface ImageUploadPayload extends BaseImagePayload {
Expand Down
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-10471-added-1715780084724.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Added
---

Tags to Image create capture tab ([#10471](https://github.com/linode/manager/pull/10471))
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-10471-tests-1715780339835.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Tests
---

Clean up and improves image creation Cypress tests ([#10471](https://github.com/linode/manager/pull/10471))
87 changes: 87 additions & 0 deletions packages/manager/cypress/e2e/core/images/create-image.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { Linode } from '@linode/api-v4';
import { authenticate } from 'support/api/authentication';
import { ui } from 'support/ui';
import { cleanUp } from 'support/util/cleanup';
import { createTestLinode } from 'support/util/linodes';
import { randomLabel, randomPhrase } from 'support/util/random';

authenticate();
describe('create image (e2e)', () => {
before(() => {
cleanUp(['linodes', 'images']);
});

it('create image from a linode', () => {
const label = randomLabel();
const description = randomPhrase();

// When Alpine 3.19 becomes deprecated, we will have to update these values for the test to pass.
const image = 'linode/alpine3.19';
const disk = 'Alpine 3.19 Disk';

cy.defer(
createTestLinode({ image }, { waitForDisks: true }),
'create linode'
).then((linode: Linode) => {
cy.visitWithLogin('/images/create');

// Find the Linode select and open it
cy.findByLabelText('Linode')
.should('be.visible')
.should('be.enabled')
.should('have.attr', 'placeholder', 'Select a Linode')
.click()
.type(linode.label);

// Select the Linode
ui.autocompletePopper
.findByTitle(linode.label)
.should('be.visible')
.should('be.enabled')
.click();

// Find the Disk select and open it
cy.findByLabelText('Disk')
.should('be.visible')
.should('be.enabled')
.click();

// Select the Linode disk
ui.autocompletePopper.findByTitle(disk).should('be.visible').click();

// Give the Image a label
cy.findByLabelText('Label')
.should('be.enabled')
.should('be.visible')
.type(label);

// Give the Image a description
cy.findByLabelText('Description')
.should('be.enabled')
.should('be.visible')
.type(description);

// Submit the image create form
ui.button
.findByTitle('Create Image')
.should('be.enabled')
.should('have.attr', 'type', 'submit')
.click();

ui.toast.assertMessage('Image scheduled for creation.');

// Verify we redirect to the images landing page upon successful creation
cy.url().should('endWith', 'images');

// Verify the newly created image shows on the Images landing page
cy.findByText(label)
.closest('tr')
.within(() => {
// Verify Image label shows
cy.findByText(label).should('be.visible');
// Verify Image has status of "Creating"
cy.findByText('Creating', { exact: false }).should('be.visible');
});
});
});
});
171 changes: 94 additions & 77 deletions packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,28 @@
import type { Linode, Disk } from '@linode/api-v4';
import { eventFactory, linodeFactory } from 'src/factories';
import { linodeDiskFactory } from 'src/factories/disk';
import { imageFactory } from 'src/factories/images';
import { authenticate } from 'support/api/authentication';
import { createLinode, deleteLinodeById } from 'support/api/linodes';
import {
mockCreateImage,
mockGetCustomImages,
} from 'support/intercepts/images';
import { mockGetLinodeDisks } from 'support/intercepts/linodes';
import { cleanUp } from 'support/util/cleanup';
import { mockGetEvents } from 'support/intercepts/events';
import { mockCreateImage } from 'support/intercepts/images';
import { mockGetLinodeDisks, mockGetLinodes } from 'support/intercepts/linodes';
import { ui } from 'support/ui';
import { randomLabel, randomNumber, randomPhrase } from 'support/util/random';

const diskLabel = 'Debian 10 Disk';

const mockDisks: Disk[] = [
{
id: 44311273,
status: 'ready',
label: diskLabel,
created: '2020-08-21T17:26:14',
updated: '2020-08-21T17:26:30',
filesystem: 'ext4',
size: 81408,
},
{
id: 44311274,
status: 'ready',
label: '512 MB Swap Image',
created: '2020-08-21T17:26:14',
updated: '2020-08-21T17:26:31',
filesystem: 'swap',
size: 512,
},
];

authenticate();
describe('create image', () => {
before(() => {
cleanUp('linodes');
});
describe('create image (using mocks)', () => {
it('create image from a linode', () => {
const mockDisks = [
linodeDiskFactory.build({ label: 'Debian 10 Disk', filesystem: 'ext4' }),
linodeDiskFactory.build({
label: '512 MB Swap Image',
filesystem: 'swap',
}),
];

const mockLinode = linodeFactory.build();

it('captures image from Linode and mocks create image', () => {
const imageLabel = randomLabel();
const imageDescription = randomPhrase();
const diskLabel = 'Debian 10 Disk';
const mockNewImage = imageFactory.build({
id: `private/${randomNumber(1000, 99999)}`,
label: imageLabel,
description: imageDescription,
label: randomLabel(),
description: randomPhrase(),
type: 'manual',
is_public: false,
vendor: null,
Expand All @@ -55,40 +31,81 @@ describe('create image', () => {
status: 'creating',
});

// stub incoming response
const mockImages = imageFactory.buildList(2);
mockGetCustomImages(mockImages).as('getImages');
mockGetLinodes([mockLinode]).as('getLinodes');
mockGetLinodeDisks(mockLinode.id, mockDisks).as('getDisks');

cy.visitWithLogin('/images/create');

// Wait for Linodes to load
cy.wait('@getLinodes');

// Find the Linode select and open it
cy.findByLabelText('Linode')
.should('be.visible')
.should('be.enabled')
.should('have.attr', 'placeholder', 'Select a Linode')
.click();

// Select the Linode
ui.autocompletePopper
.findByTitle(mockLinode.label)
.should('be.visible')
.should('be.enabled')
.click();

// Verify disks load when a Linode is selected
cy.wait('@getDisks');

// Find the Disk select and open it
cy.findByLabelText('Disk')
.should('be.visible')
.should('be.enabled')
.click();

// Select the Linode disk
ui.autocompletePopper
.findByTitle(mockDisks[0].label)
.should('be.visible')
.click();

// Give the Image a label
cy.findByLabelText('Label')
.should('be.enabled')
.should('be.visible')
.type(mockNewImage.label);

// Give the Image a description
cy.findByLabelText('Description')
.should('be.enabled')
.should('be.visible')
.type(mockNewImage.description!);

// Mock the Image creation POST response
mockCreateImage(mockNewImage).as('createImage');
createLinode().then((linode: Linode) => {
// stub incoming disks response
mockGetLinodeDisks(linode.id, mockDisks).as('getDisks');
cy.visitWithLogin('/images');
cy.get('[data-qa-header]')
.should('be.visible')
.within(() => {
cy.findByText('Images').should('be.visible');
});

cy.get('[data-qa-header]')
.should('be.visible')
.within(() => {
cy.findByText('Images').should('be.visible');
});

cy.wait('@getImages');
cy.findByText('Create Image').click();
cy.findByLabelText('Linode').click();
cy.findByText(linode.label).click();
cy.wait('@getDisks');
cy.contains('Select a Disk').click().type(`${diskLabel}{enter}`);
cy.findAllByLabelText('Label', { exact: false }).type(
`${imageLabel}{enter}`
);
cy.findAllByLabelText('Description').type(imageDescription);
cy.get('[data-qa-submit]').click();
cy.wait('@createImage');
cy.url().should('endWith', 'images');
deleteLinodeById(linode.id);
});

// Submit the image create form
ui.button
.findByTitle('Create Image')
.should('be.enabled')
.should('have.attr', 'type', 'submit')
.click();

// Verify the POST /v4/images request happens
cy.wait('@createImage');

ui.toast.assertMessage('Image scheduled for creation.');

// Verify we redirect to the images landing page upon successful creation
cy.url().should('endWith', 'images');

mockGetEvents([
eventFactory.build({ action: 'disk_imagize', status: 'finished' }),
]).as('getEvents');

// Wait for the next events polling request
cy.wait('@getEvents');

// Verify a success toast shows
ui.toast.assertMessage('Image My Config successfully created.');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,9 @@ const createLinodeAndImage = async () => {
await resizeLinodeDisk(linode.id, diskId, resizedDiskSize);
await pollLinodeDiskSize(linode.id, diskId, resizedDiskSize);

const image = await createImage(diskId, randomLabel(), randomPhrase());
const image = await createImage({
disk_id: diskId,
});

await pollImageStatus(
image.id,
Expand Down
6 changes: 6 additions & 0 deletions packages/manager/src/components/TagsInput/TagsInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ export interface TagsInputProps {
* The name of the input.
*/
name?: string;
/**
* Removes the default top margin
*/
noMarginTop?: boolean;
/**
* Callback fired when the value changes.
*/
Expand All @@ -60,6 +64,7 @@ export const TagsInput = (props: TagsInputProps) => {
label,
menuPlacement,
name,
noMarginTop,
onChange,
tagError,
value,
Expand Down Expand Up @@ -132,6 +137,7 @@ export const TagsInput = (props: TagsInputProps) => {
label={label || 'Add Tags'}
menuPlacement={menuPlacement}
name={name}
noMarginTop={noMarginTop}
noOptionsMessage={getEmptyMessage}
onChange={onChange}
onCreateOption={createTag}
Expand Down
Loading

0 comments on commit 4b4a3da

Please sign in to comment.