Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [M3-8103] - Refactor Image Create and Add Tags #10471

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
Loading