From 79448018c450c536b492f72d96b650e30e9e89b2 Mon Sep 17 00:00:00 2001 From: minnakt <47064971+minnakt@users.noreply.github.com> Date: Wed, 26 Feb 2025 20:27:36 -0500 Subject: [PATCH] DEVPROD-12285: Allow users to modify volume size (#656) --- .../cypress/integration/spawn/volume.ts | 27 +++++++++++++- .../Spawn/editVolumeModal/getFormSchema.tsx | 16 ++++++++- .../Spawn/editVolumeModal/transformer.ts | 11 +++--- .../components/Spawn/editVolumeModal/types.ts | 1 + apps/spruce/src/pages/spawn/SpawnVolume.tsx | 5 ++- .../spawn/spawnVolume/SpawnVolumeTable.tsx | 16 +++++++-- .../spawnVolume/SpawnVolumeTableActions.tsx | 12 +++++-- .../spawnVolumeTableActions/EditButton.tsx | 4 ++- .../EditVolumeModal.tsx | 35 ++++++++++++------- 9 files changed, 101 insertions(+), 26 deletions(-) diff --git a/apps/spruce/cypress/integration/spawn/volume.ts b/apps/spruce/cypress/integration/spawn/volume.ts index 603a59bf4..ba6215ce8 100644 --- a/apps/spruce/cypress/integration/spawn/volume.ts +++ b/apps/spruce/cypress/integration/spawn/volume.ts @@ -151,7 +151,7 @@ describe("Spawn volume page", () => { cy.dataCy("update-volume-modal").should("be.visible"); }); - it("Volume name & expiration inputs should be populated with the volume display name & expiration on initial render", () => { + it("name, size, expiration inputs should be populated on initial render", () => { cy.dataCy( "edit-btn-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b858", ).click(); @@ -160,6 +160,7 @@ describe("Spawn volume page", () => { "have.value", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b858", ); + cy.dataCy("volume-size-input").should("have.value", "100"); cy.dataCy("date-picker").should("have.value", "2020-06-06"); cy.dataCy("time-picker").should("have.value", "15:48:18"); // Defaults to UTC }); @@ -182,6 +183,30 @@ describe("Spawn volume page", () => { ); }); + it("size field is validated correctly", () => { + cy.dataCy( + "edit-btn-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b858", + ).click(); + cy.dataCy("update-volume-modal").should("be.visible"); + + // Exceeding max volume limit should disable 'Save' button. + cy.dataCy("volume-size-input").clear(); + cy.dataCy("volume-size-input").type("10000"); + cy.contains("button", "Save").should( + "have.attr", + "aria-disabled", + "true", + ); + // Decreasing volume size should disable 'Save' button. + cy.dataCy("volume-size-input").clear(); + cy.dataCy("volume-size-input").type("2"); + cy.contains("button", "Save").should( + "have.attr", + "aria-disabled", + "true", + ); + }); + it("Submit button should be enabled when the volume details input value differs from what already exists.", () => { cy.dataCy( "edit-btn-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b858", diff --git a/apps/spruce/src/components/Spawn/editVolumeModal/getFormSchema.tsx b/apps/spruce/src/components/Spawn/editVolumeModal/getFormSchema.tsx index 33491284f..852568d35 100644 --- a/apps/spruce/src/components/Spawn/editVolumeModal/getFormSchema.tsx +++ b/apps/spruce/src/components/Spawn/editVolumeModal/getFormSchema.tsx @@ -6,12 +6,16 @@ import { ExpirationRow } from "../ExpirationRow"; interface Props { disableExpirationCheckbox: boolean; hasName: boolean; + maxSpawnableLimit: number; + minVolumeSize: number; noExpirationCheckboxTooltip: string; } export const getFormSchema = ({ disableExpirationCheckbox, hasName, + maxSpawnableLimit, + minVolumeSize, noExpirationCheckboxTooltip, }: Props): ReturnType => ({ fields: {}, @@ -24,6 +28,12 @@ export const getFormSchema = ({ // The back end requires a name if one has previously been set, so prevent users from unsetting a name. ...(hasName && { minLength: 1 }), }, + size: { + type: "number", + title: "Volume Size (GiB)", + minimum: minVolumeSize, + maximum: maxSpawnableLimit, + }, expirationDetails: { type: "object", properties: { @@ -69,6 +79,10 @@ export const getFormSchema = ({ name: { "ui:data-cy": "volume-name-input", }, + size: { + "ui:data-cy": "volume-size-input", + "ui:description": `The max volume size is ${maxSpawnableLimit} GiB. Volume size can only be updated once every 6 hours, and cannot be decreased.`, + }, expirationDetails: { "ui:ObjectFieldTemplate": ExpirationRow, expiration: { @@ -79,7 +93,7 @@ export const getFormSchema = ({ }, noExpiration: { "ui:disabled": disableExpirationCheckbox, - "ui:tooltipDescription": noExpirationCheckboxTooltip ?? "", + "ui:tooltipDescription": noExpirationCheckboxTooltip, "ui:elementWrapperCSS": checkboxCSS, }, }, diff --git a/apps/spruce/src/components/Spawn/editVolumeModal/transformer.ts b/apps/spruce/src/components/Spawn/editVolumeModal/transformer.ts index 2e04d47b1..3ebe1712b 100644 --- a/apps/spruce/src/components/Spawn/editVolumeModal/transformer.ts +++ b/apps/spruce/src/components/Spawn/editVolumeModal/transformer.ts @@ -7,17 +7,18 @@ export const formToGql = ( volumeId: string, ) => { const updatedFields: Partial = diff(initialState, formData); + const { expirationDetails, name = "", size } = updatedFields; + const { - expirationDetails = {} as FormState["expirationDetails"], - name = "", - } = updatedFields; - // @ts-expect-error: FIXME. This comment was added by an automated script. - const { expiration, noExpiration } = expirationDetails; + expiration = formData.expirationDetails?.expiration, + noExpiration = formData.expirationDetails?.noExpiration, + } = expirationDetails ?? {}; return { ...(noExpiration && { noExpiration }), ...(expiration && !noExpiration && { expiration: new Date(expiration) }), ...(name && { name }), + ...(size && { size }), volumeId, }; }; diff --git a/apps/spruce/src/components/Spawn/editVolumeModal/types.ts b/apps/spruce/src/components/Spawn/editVolumeModal/types.ts index ee05cb881..9841b0f4a 100644 --- a/apps/spruce/src/components/Spawn/editVolumeModal/types.ts +++ b/apps/spruce/src/components/Spawn/editVolumeModal/types.ts @@ -4,4 +4,5 @@ export type FormState = { expiration?: string; noExpiration: boolean; }; + size?: number; }; diff --git a/apps/spruce/src/pages/spawn/SpawnVolume.tsx b/apps/spruce/src/pages/spawn/SpawnVolume.tsx index 6cf38f945..818179a6e 100644 --- a/apps/spruce/src/pages/spawn/SpawnVolume.tsx +++ b/apps/spruce/src/pages/spawn/SpawnVolume.tsx @@ -75,7 +75,10 @@ export const SpawnVolume = () => { volumeLimit={volumeLimit} /> {volumes.length ? ( - + ) : ( No volumes available, spawn one to get started. )} diff --git a/apps/spruce/src/pages/spawn/spawnVolume/SpawnVolumeTable.tsx b/apps/spruce/src/pages/spawn/spawnVolume/SpawnVolumeTable.tsx index b5a5313d5..572547c67 100644 --- a/apps/spruce/src/pages/spawn/spawnVolume/SpawnVolumeTable.tsx +++ b/apps/spruce/src/pages/spawn/spawnVolume/SpawnVolumeTable.tsx @@ -15,10 +15,12 @@ import { SpawnVolumeTableActions } from "./SpawnVolumeTableActions"; import { VolumeStatusBadge } from "./VolumeStatusBadge"; interface SpawnVolumeTableProps { + maxSpawnableLimit: number; volumes: MyVolume[]; } export const SpawnVolumeTable: React.FC = ({ + maxSpawnableLimit, volumes, }) => { const [selectedVolume] = useQueryParam(QueryParams.Volume, ""); @@ -38,6 +40,11 @@ export const SpawnVolumeTable: React.FC = ({ dataSource.map(({ id }, i) => [i, id === selectedVolume]), ); + const columns = useMemo( + () => getColumns(maxSpawnableLimit), + [maxSpawnableLimit], + ); + const tableContainerRef = useRef(null); const table = useLeafyGreenTable({ columns, @@ -65,7 +72,7 @@ const getHostDisplayName = (v: TableVolume) => const sortByHost = (a: TableVolume, b: TableVolume) => getHostDisplayName(a).localeCompare(getHostDisplayName(b)); -const columns = [ +const getColumns = (maxSpawnableLimit: number) => [ { header: "Volume", // @ts-expect-error: FIXME. This comment was added by an automated script. @@ -134,7 +141,12 @@ const columns = [ { header: "Actions", // @ts-expect-error: FIXME. This comment was added by an automated script. - cell: ({ row }) => , + cell: ({ row }) => ( + + ), }, ]; diff --git a/apps/spruce/src/pages/spawn/spawnVolume/SpawnVolumeTableActions.tsx b/apps/spruce/src/pages/spawn/spawnVolume/SpawnVolumeTableActions.tsx index 6fdfc4976..701601c26 100644 --- a/apps/spruce/src/pages/spawn/spawnVolume/SpawnVolumeTableActions.tsx +++ b/apps/spruce/src/pages/spawn/spawnVolume/SpawnVolumeTableActions.tsx @@ -8,10 +8,14 @@ import { MountButton } from "./spawnVolumeTableActions/MountButton"; import { UnmountButton } from "./spawnVolumeTableActions/UnmountButton"; interface Props { + maxSpawnableLimit: number; volume: TableVolume; } -export const SpawnVolumeTableActions: React.FC = ({ volume }) => { +export const SpawnVolumeTableActions: React.FC = ({ + maxSpawnableLimit, + volume, +}) => { const { displayName, homeVolume, host, id } = volume; return ( = ({ volume }) => { {!host && !homeVolume && ( )} - + ); }; diff --git a/apps/spruce/src/pages/spawn/spawnVolume/spawnVolumeTableActions/EditButton.tsx b/apps/spruce/src/pages/spawn/spawnVolume/spawnVolumeTableActions/EditButton.tsx index da82f931f..6d6c6ceff 100644 --- a/apps/spruce/src/pages/spawn/spawnVolume/spawnVolumeTableActions/EditButton.tsx +++ b/apps/spruce/src/pages/spawn/spawnVolume/spawnVolumeTableActions/EditButton.tsx @@ -4,10 +4,11 @@ import { TableVolume } from "types/spawn"; import { EditVolumeModal } from "./EditVolumeModal"; interface Props { + maxSpawnableLimit: number; volume: TableVolume; } -export const EditButton: React.FC = ({ volume }) => { +export const EditButton: React.FC = ({ maxSpawnableLimit, volume }) => { const [openModal, setOpenModal] = useState(false); return ( @@ -25,6 +26,7 @@ export const EditButton: React.FC = ({ volume }) => { {openModal && ( setOpenModal(false)} visible={openModal} volume={volume} diff --git a/apps/spruce/src/pages/spawn/spawnVolume/spawnVolumeTableActions/EditVolumeModal.tsx b/apps/spruce/src/pages/spawn/spawnVolume/spawnVolumeTableActions/EditVolumeModal.tsx index 8b9a5b943..306fad25b 100644 --- a/apps/spruce/src/pages/spawn/spawnVolume/spawnVolumeTableActions/EditVolumeModal.tsx +++ b/apps/spruce/src/pages/spawn/spawnVolume/spawnVolumeTableActions/EditVolumeModal.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { useMutation } from "@apollo/client"; +import { AjvError } from "@rjsf/core"; import { diff } from "deep-object-diff"; import { useToastContext } from "@evg-ui/lib/context/toast"; import { useSpawnAnalytics } from "analytics"; @@ -19,12 +20,14 @@ import { UPDATE_SPAWN_VOLUME } from "gql/mutations"; import { TableVolume } from "types/spawn"; interface Props { - visible: boolean; + maxSpawnableLimit: number; onCancel: () => void; + visible: boolean; volume: TableVolume; } export const EditVolumeModal: React.FC = ({ + maxSpawnableLimit, onCancel, visible, volume, @@ -52,36 +55,39 @@ export const EditVolumeModal: React.FC = ({ const initialState = useMemo( () => ({ expirationDetails: { - // @ts-expect-error: FIXME. This comment was added by an automated script. - expiration: new Date(volume?.expiration).toString(), + expiration: volume?.expiration + ? new Date(volume?.expiration).toString() + : undefined, noExpiration: volume.noExpiration, }, name: volume.displayName, + size: volume.size, }), [volume], ); const [formState, setFormState] = useState(initialState); - const [formErrors, setFormErrors] = useState([]); + const [formErrors, setFormErrors] = useState([]); const updateVolume = () => { const mutationInput = formToGql(initialState, formState, volume.id); spawnAnalytics.sendEvent({ name: "Changed spawn volume settings", - "volume.is_unexpirable": mutationInput.noExpiration, + "volume.is_unexpirable": mutationInput.noExpiration ?? false, }); updateVolumeMutation({ variables: { updateVolumeInput: mutationInput }, }); }; - const { disableExpirationCheckbox, noExpirationCheckboxTooltip } = + const { disableExpirationCheckbox, noExpirationCheckboxTooltip = "" } = useLoadFormData(volume); const { schema, uiSchema } = getFormSchema({ + maxSpawnableLimit, + minVolumeSize: volume.size, disableExpirationCheckbox, - // @ts-expect-error: FIXME. This comment was added by an automated script. noExpirationCheckboxTooltip, - hasName: !!initialState?.name?.length, + hasName: initialState?.name?.length > 0, }); const hasChanges = useMemo(() => { @@ -91,19 +97,22 @@ export const EditVolumeModal: React.FC = ({ return ( { setFormState(formData); - // @ts-expect-error: FIXME. This comment was added by an automated script. setFormErrors(errors); }} schema={schema}