From 8485b52ff336aacb4fe2d3820c1e6ad331282211 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Thu, 5 Dec 2024 17:54:17 -0500 Subject: [PATCH 001/109] refactor: [M3-8954] - Update `@hookform/resolvers` to latest (#11349) * update hookform resolvers and try to fix schema issues * fix import * fix acl regression * revert linode create resolver change --------- Co-authored-by: Banks Nussman --- packages/api-v4/src/cloudpulse/types.ts | 3 +-- packages/manager/package.json | 2 +- .../CreateAlert/CreateAlertDefinition.tsx | 8 ++++--- .../Images/ImagesCreate/ImageUpload.utils.ts | 4 +++- .../Linodes/LinodeCreate/resolvers.ts | 6 +++-- packages/validation/src/buckets.schema.ts | 13 ++++++++-- packages/validation/src/cloudpulse.schema.ts | 24 +++++++++++-------- packages/validation/src/images.schema.ts | 14 +++++------ packages/validation/src/kubernetes.schema.ts | 14 ++++++----- yarn.lock | 8 +++---- 10 files changed, 58 insertions(+), 38 deletions(-) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 4b64bf16c30..765a1d71ba1 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -148,14 +148,13 @@ export interface CreateAlertDefinitionPayload { rule_criteria: { rules: MetricCriteria[]; }; - triggerCondition: TriggerCondition; + trigger_condition: TriggerCondition; channel_ids: number[]; } export interface MetricCriteria { metric: string; aggregation_type: MetricAggregationType; operator: MetricOperatorType; - value: number; dimension_filters: DimensionFilter[]; } diff --git a/packages/manager/package.json b/packages/manager/package.json index edfe0b65f0d..f5a9416479c 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -19,7 +19,7 @@ "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", - "@hookform/resolvers": "2.9.11", + "@hookform/resolvers": "3.9.1", "@linode/api-v4": "*", "@linode/design-language-system": "^2.6.1", "@linode/search": "*", diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx index 61a6822075e..7132fae1455 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx @@ -19,6 +19,7 @@ import { filterFormValues, filterMetricCriteriaFormValues } from './utilities'; import type { CreateAlertDefinitionForm, MetricCriteriaForm } from './types'; import type { TriggerCondition } from '@linode/api-v4/lib/cloudpulse/types'; +import type { ObjectSchema } from 'yup'; const triggerConditionInitialValues: TriggerCondition = { evaluation_period_seconds: 0, @@ -30,7 +31,6 @@ const criteriaInitialValues: MetricCriteriaForm = { dimension_filters: [], metric: '', operator: null, - value: 0, }; const initialValues: CreateAlertDefinitionForm = { channel_ids: [], @@ -43,7 +43,7 @@ const initialValues: CreateAlertDefinitionForm = { }, serviceType: null, severity: null, - triggerCondition: triggerConditionInitialValues, + trigger_condition: triggerConditionInitialValues, }; const overrides = [ @@ -65,7 +65,9 @@ export const CreateAlertDefinition = () => { const formMethods = useForm({ defaultValues: initialValues, mode: 'onBlur', - resolver: yupResolver(CreateAlertDefinitionFormSchema), + resolver: yupResolver( + CreateAlertDefinitionFormSchema as ObjectSchema + ), }); const { diff --git a/packages/manager/src/features/Images/ImagesCreate/ImageUpload.utils.ts b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.utils.ts index 8e890f341f7..c326350ea26 100644 --- a/packages/manager/src/features/Images/ImagesCreate/ImageUpload.utils.ts +++ b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.utils.ts @@ -28,7 +28,9 @@ export interface ImageUploadFormData extends ImageUploadPayload { * form state at once. */ export const ImageUploadSchema = uploadImageSchema.shape({ - file: mixed().required('Image is required.'), + file: mixed((input): input is File => input instanceof File).required( + 'Image is required.' + ), }); /** diff --git a/packages/manager/src/features/Linodes/LinodeCreate/resolvers.ts b/packages/manager/src/features/Linodes/LinodeCreate/resolvers.ts index e42e476cdc3..94425df2595 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/resolvers.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/resolvers.ts @@ -18,8 +18,10 @@ import type { LinodeCreateFormContext, LinodeCreateFormValues, } from './utilities'; +import type { CreateLinodeRequest } from '@linode/api-v4'; import type { QueryClient } from '@tanstack/react-query'; import type { FieldErrors, Resolver } from 'react-hook-form'; +import type { ObjectSchema } from 'yup'; export const getLinodeCreateResolver = ( tab: LinodeCreateType | undefined, @@ -30,9 +32,9 @@ export const getLinodeCreateResolver = ( const transformedValues = getLinodeCreatePayload(structuredClone(values)); const { errors } = await yupResolver( - schema, + schema as ObjectSchema, {}, - { mode: 'async', rawValues: true } + { mode: 'async', raw: true } )(transformedValues, context, options); if (tab === 'Clone Linode' && !values.linode) { diff --git a/packages/validation/src/buckets.schema.ts b/packages/validation/src/buckets.schema.ts index bb14be983a4..0f5c6a02668 100644 --- a/packages/validation/src/buckets.schema.ts +++ b/packages/validation/src/buckets.schema.ts @@ -47,8 +47,17 @@ export const CreateBucketSchema = object() }), endpoint_type: string() .oneOf([...ENDPOINT_TYPES]) - .notRequired(), - cors_enabled: boolean().notRequired(), + .optional(), + cors_enabled: boolean().optional(), + acl: string() + .oneOf([ + 'private', + 'public-read', + 'authenticated-read', + 'public-read-write', + ]) + .optional(), + s3_endpoint: string().optional(), }, [['cluster', 'region']] ) diff --git a/packages/validation/src/cloudpulse.schema.ts b/packages/validation/src/cloudpulse.schema.ts index c639deb9d7d..c7a0317e268 100644 --- a/packages/validation/src/cloudpulse.schema.ts +++ b/packages/validation/src/cloudpulse.schema.ts @@ -16,10 +16,9 @@ const metricCriteria = object({ dimension_filters: array().of(dimensionFilters).notRequired(), }); -const triggerCondition = object({ - criteria_condition: string().required('Criteria condition is required.'), - polling_interval_seconds: string().required('Polling Interval is required.'), - evaluation_period_seconds: string().required( +const trigger_condition = object({ + polling_interval_seconds: number().required('Polling Interval is required.'), + evaluation_period_seconds: number().required( 'Evaluation Period is required.' ), trigger_occurrences: number() @@ -30,10 +29,15 @@ const triggerCondition = object({ export const createAlertDefinitionSchema = object({ label: string().required('Name is required.'), description: string().optional(), - severity: string().required('Severity is required.'), - entity_ids: array().of(string()).min(1, 'At least one resource is needed.'), - criteria: array() - .of(metricCriteria) - .min(1, 'At least one metric criteria is needed.'), - triggerCondition, + severity: number().oneOf([0, 1, 2, 3]).required('Severity is required.'), + entity_ids: array() + .of(string().required()) + .min(1, 'At least one resource is needed.'), + rule_criteria: object({ + rules: array() + .of(metricCriteria) + .min(1, 'At least one metric criteria is needed.'), + }), + trigger_condition, + channel_ids: array(number()), }); diff --git a/packages/validation/src/images.schema.ts b/packages/validation/src/images.schema.ts index c8b3b6f9a45..de9470a17eb 100644 --- a/packages/validation/src/images.schema.ts +++ b/packages/validation/src/images.schema.ts @@ -9,10 +9,10 @@ const labelSchema = string() ); export const baseImageSchema = object({ - label: labelSchema.notRequired(), - description: string().notRequired().min(1).max(65000), - cloud_init: boolean().notRequired(), - tags: array(string().min(3).max(50)).max(500).notRequired(), + label: labelSchema.optional(), + description: string().optional().min(1).max(65000), + cloud_init: boolean().optional(), + tags: array(string().min(3).max(50).required()).max(500).optional(), }); export const createImageSchema = baseImageSchema.shape({ @@ -27,11 +27,11 @@ export const uploadImageSchema = baseImageSchema.shape({ }); export const updateImageSchema = object({ - label: labelSchema.notRequired(), + label: labelSchema.optional(), description: string() - .notRequired() + .optional() .max(65000, 'Length must be 65000 characters or less.'), - tags: array(string()).notRequired(), + tags: array(string().required()).optional(), }); export const updateImageRegionsSchema = object({ diff --git a/packages/validation/src/kubernetes.schema.ts b/packages/validation/src/kubernetes.schema.ts index b16ecd24493..8789aaa34ae 100644 --- a/packages/validation/src/kubernetes.schema.ts +++ b/packages/validation/src/kubernetes.schema.ts @@ -62,13 +62,13 @@ export const createKubeClusterSchema = object().shape({ .min(1, 'Please add at least one node pool.'), }); -export const ipv4Address = string().test({ +export const ipv4Address = string().defined().test({ name: 'validateIP', message: 'Must be a valid IPv4 address.', test: validateIP, }); -export const ipv6Address = string().test({ +export const ipv6Address = string().defined().test({ name: 'validateIP', message: 'Must be a valid IPv6 address.', test: validateIP, @@ -77,10 +77,12 @@ export const ipv6Address = string().test({ const controlPlaneACLOptionsSchema = object().shape({ enabled: boolean(), 'revision-id': string(), - addresses: object().shape({ - ipv4: array().of(ipv4Address).nullable(), - ipv6: array().of(ipv6Address).nullable(), - }), + addresses: object() + .shape({ + ipv4: array().of(ipv4Address).nullable(), + ipv6: array().of(ipv6Address).nullable(), + }) + .notRequired(), }); export const kubernetesControlPlaneACLPayloadSchema = object().shape({ diff --git a/yarn.lock b/yarn.lock index 8e20c26e521..a3b29ac1e39 100644 --- a/yarn.lock +++ b/yarn.lock @@ -949,10 +949,10 @@ dependencies: levn "^0.4.1" -"@hookform/resolvers@2.9.11": - version "2.9.11" - resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-2.9.11.tgz#9ce96e7746625a89239f68ca57c4f654264c17ef" - integrity sha512-bA3aZ79UgcHj7tFV7RlgThzwSSHZgvfbt2wprldRkYBcMopdMvHyO17Wwp/twcJasNFischFfS7oz8Katz8DdQ== +"@hookform/resolvers@3.9.1": + version "3.9.1" + resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-3.9.1.tgz#a23883c40bfd449cb6c6ab5a0fa0729184c950ff" + integrity sha512-ud2HqmGBM0P0IABqoskKWI6PEf6ZDDBZkFqe2Vnl+mTHCEHzr3ISjjZyCwTjC/qpL25JC9aIDkloQejvMeq0ug== "@humanwhocodes/config-array@^0.5.0": version "0.5.0" From 3d62d317dbadbcea033ce28ab2879e6418d3b68b Mon Sep 17 00:00:00 2001 From: Harsh Shankar Rao Date: Fri, 6 Dec 2024 12:08:09 +0530 Subject: [PATCH 002/109] fix: [M3-8912] - Create support ticket for buckets created through legacy flow (#11300) * fix: [M3-8912] - Create support ticket for bucket created through legacy flow * Added changeset: Create support ticket for buckets created through legacy flow --- .../pr-11300-fixed-1732196456888.md | 5 +++++ .../SupportTicketProductSelectionFields.tsx | 21 +++++++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-11300-fixed-1732196456888.md diff --git a/packages/manager/.changeset/pr-11300-fixed-1732196456888.md b/packages/manager/.changeset/pr-11300-fixed-1732196456888.md new file mode 100644 index 00000000000..39f6af7ae30 --- /dev/null +++ b/packages/manager/.changeset/pr-11300-fixed-1732196456888.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Create support ticket for buckets created through legacy flow ([#11300](https://github.com/linode/manager/pull/11300)) diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx index 3c18dce0534..b5c6eaf7eea 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx @@ -132,10 +132,12 @@ export const SupportTicketProductSelectionFields = (props: Props) => { if (entityType === 'bucket') { return ( reactQueryEntityDataMap['bucket']?.buckets?.map( - ({ label, region }) => ({ - label, - value: region ?? '', - }) + ({ cluster, label, region }) => { + return { + label, + value: region ?? cluster, + }; + } ) || [] ); } @@ -185,8 +187,15 @@ export const SupportTicketProductSelectionFields = (props: Props) => { : undefined; const selectedEntity = - entityOptions.find((thisEntity) => String(thisEntity.value) === entityId) || - null; + entityType === 'bucket' + ? entityOptions.find( + (thisEntity) => + String(thisEntity.value) === entityId && + thisEntity.label === entityInputValue + ) || null + : entityOptions.find( + (thisEntity) => String(thisEntity.value) === entityId + ) || null; const renderEntityTypes = () => { return Object.keys(ENTITY_MAP).map((key: string) => { From 82c89902185be4e6f9b4c356b46eb9d9174d9adc Mon Sep 17 00:00:00 2001 From: Harsh Shankar Rao Date: Fri, 6 Dec 2024 12:09:14 +0530 Subject: [PATCH 003/109] test: [M3-8758]- Fixed `delete-volume.spec.ts` flaky test (#11365) * test: [M3-8758]- Flake flaky test * Added changeset: Fix `delete-volume.spec.ts` flaky test --- .../pr-11365-tests-1733305182663.md | 5 +++++ .../e2e/core/volumes/delete-volume.spec.ts | 19 +++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-11365-tests-1733305182663.md diff --git a/packages/manager/.changeset/pr-11365-tests-1733305182663.md b/packages/manager/.changeset/pr-11365-tests-1733305182663.md new file mode 100644 index 00000000000..59be165493e --- /dev/null +++ b/packages/manager/.changeset/pr-11365-tests-1733305182663.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix `delete-volume.spec.ts` flaky test ([#11365](https://github.com/linode/manager/pull/11365)) diff --git a/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts index 6828618fb70..be6d7f8dec1 100644 --- a/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts @@ -1,4 +1,4 @@ -import { createVolume } from '@linode/api-v4/lib/volumes'; +import { createVolume, VolumeRequestPayload } from '@linode/api-v4/lib/volumes'; import { Volume } from '@linode/api-v4'; import { volumeRequestPayloadFactory } from 'src/factories/volume'; import { authenticate } from 'support/api/authentication'; @@ -7,6 +7,8 @@ import { randomLabel } from 'support/util/random'; import { ui } from 'support/ui'; import { chooseRegion } from 'support/util/regions'; import { cleanUp } from 'support/util/cleanup'; +import { SimpleBackoffMethod } from 'support/util/backoff'; +import { pollVolumeStatus } from 'support/util/polling'; // Local storage override to force volume table to list up to 100 items. // This is a workaround while we wait to get stuck volumes removed. @@ -15,6 +17,19 @@ const pageSizeOverride = { PAGE_SIZE: 100, }; +/** + * Creates a Volume and waits for it to become active. + * + * @param volumeRequest - Volume create request payload. + * + * @returns Promise that resolves to created Volume. + */ +const createActiveVolume = async (volumeRequest: VolumeRequestPayload) => { + const volume = await createVolume(volumeRequest); + await pollVolumeStatus(volume.id, 'active', new SimpleBackoffMethod(10000)); + return volume; +}; + authenticate(); describe('volume delete flow', () => { before(() => { @@ -37,7 +52,7 @@ describe('volume delete flow', () => { region: chooseRegion().id, }); - cy.defer(() => createVolume(volumeRequest), 'creating volume').then( + cy.defer(() => createActiveVolume(volumeRequest), 'creating volume').then( (volume: Volume) => { interceptDeleteVolume(volume.id).as('deleteVolume'); cy.visitWithLogin('/volumes', { From 04f241dc8d7138471e29a180d7d3164e64f2f84c Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Fri, 6 Dec 2024 09:39:29 -0500 Subject: [PATCH 004/109] added: [M3-6989] - `aria-describedby` to TextField with helper text (#11351) * Add aria-describedby to TextField with helper text * Always some ID * cleanup * add coverage * Added changeset: aria-describedby to TextField with helper text * cleanup * feedback @hkhalil-akamai --- .../pr-11351-added-1733194164261.md | 5 +++ .../components/TextField/TextField.test.tsx | 32 +++++++++++++++++++ .../ui/src/components/TextField/TextField.tsx | 19 +++++++++-- 3 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 packages/manager/.changeset/pr-11351-added-1733194164261.md diff --git a/packages/manager/.changeset/pr-11351-added-1733194164261.md b/packages/manager/.changeset/pr-11351-added-1733194164261.md new file mode 100644 index 00000000000..64a4c8c5703 --- /dev/null +++ b/packages/manager/.changeset/pr-11351-added-1733194164261.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +aria-describedby to TextField with helper text ([#11351](https://github.com/linode/manager/pull/11351)) diff --git a/packages/ui/src/components/TextField/TextField.test.tsx b/packages/ui/src/components/TextField/TextField.test.tsx index bdf4eb45919..57cfe760045 100644 --- a/packages/ui/src/components/TextField/TextField.test.tsx +++ b/packages/ui/src/components/TextField/TextField.test.tsx @@ -91,4 +91,36 @@ describe('TextField', () => { fireEvent.change(input, { target: { value: '1' } }); expect(input?.getAttribute('value')).toBe('2'); }); + + it('renders a helper text with an input id', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Helper text')).toBeInTheDocument(); + const helperText = getByText('Helper text'); + expect(helperText.getAttribute('id')).toBe('input-id-helper-text'); + }); + + it('renders a helper text with a label', () => { + const { getByText } = renderWithTheme( + + ); + + const helperText = getByText('Helper text'); + + expect(helperText).toBeInTheDocument(); + expect(helperText.getAttribute('id')).toBe('label-helper-text'); + }); + + it('renders a helper text with a fallback id', () => { + const { getByText } = renderWithTheme( + + ); + + const helperText = getByText('Helper text'); + + // ':rg:' being the default react generated id + expect(helperText.getAttribute('id')).toBe(':rg:-helper-text'); + }); }); diff --git a/packages/ui/src/components/TextField/TextField.tsx b/packages/ui/src/components/TextField/TextField.tsx index 8d52fdec749..0ab581d712d 100644 --- a/packages/ui/src/components/TextField/TextField.tsx +++ b/packages/ui/src/components/TextField/TextField.tsx @@ -113,7 +113,8 @@ interface InputToolTipProps { tooltipWidth?: number; } -interface TextFieldPropsOverrides extends StandardTextFieldProps { +interface TextFieldPropsOverrides + extends Omit { // We override this prop to make it required label: string; } @@ -166,6 +167,7 @@ export const TextField = (props: TextFieldProps) => { const [_value, setValue] = React.useState(value); const theme = useTheme(); + const fallbackId = React.useId(); React.useEffect(() => { setValue(value); @@ -249,7 +251,14 @@ export const TextField = (props: TextFieldProps) => { } const validInputId = - inputId || (label ? convertToKebabCase(`${label}`) : undefined); + inputId || + (label + ? convertToKebabCase(label) + : // label could still be an empty string + fallbackId); + + const helperTextId = `${validInputId}-helper-text`; + const errorTextId = `${validInputId}-error-text`; const labelSuffixText = required ? '(required)' @@ -316,6 +325,7 @@ export const TextField = (props: TextFieldProps) => { marginTop: 0, }} data-qa-textfield-helper-text + id={helperTextId} > {helperText} @@ -363,6 +373,9 @@ export const TextField = (props: TextFieldProps) => { ...SelectProps, }} inputProps={{ + 'aria-describedby': helperText ? helperTextId : undefined, + 'aria-errormessage': errorText ? errorTextId : undefined, + 'aria-invalid': !!error || !!errorText, 'data-testid': 'textfield-input', id: validInputId, ...inputProps, @@ -436,7 +449,7 @@ export const TextField = (props: TextFieldProps) => { )} {helperText && helperTextPosition === 'bottom' && ( - + {helperText} )} From 5ac188d110755dab7d406c496adf1846f943ed69 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Fri, 6 Dec 2024 09:57:49 -0500 Subject: [PATCH 005/109] tech-story: [M3-8940] - Dev Tools fixes and improvements (#11328) * nested JSON flags * fix overflow * Styling updates * save progress * save progress * Fix types * Wrapping up account customization * form improvements profile * cleanup * Added changeset: Dev Tools fixes and improvements * fix flake * feedback @jaalah-akamai --- packages/api-v4/src/account/types.ts | 55 +-- .../pr-11328-tech-stories-1732650627657.md | 5 + .../dev-tools/{dev-tools.tsx => DevTools.tsx} | 0 .../manager/src/dev-tools/FeatureFlagTool.tsx | 136 +++++-- .../src/dev-tools/ServiceWorkerTool.tsx | 189 +++++---- .../src/dev-tools/components/Draggable.tsx | 6 +- .../components/ExtraPresetAccount.tsx | 370 ++++++++++++++++++ .../components/ExtraPresetOptionCheckbox.tsx | 21 +- .../components/ExtraPresetOptionSelect.tsx | 3 +- .../components/ExtraPresetOptions.tsx | 32 +- .../components/ExtraPresetProfile.tsx | 310 +++++++++++++++ .../src/dev-tools/components/JsonTextArea.tsx | 91 +++++ .../src/dev-tools/components/SeedOptions.tsx | 17 +- packages/manager/src/dev-tools/constants.ts | 4 + packages/manager/src/dev-tools/dev-tools.css | 71 +++- packages/manager/src/dev-tools/load.ts | 2 +- packages/manager/src/dev-tools/utils.ts | 43 ++ packages/manager/src/factories/account.ts | 14 +- packages/manager/src/factories/profile.ts | 4 +- .../src/features/Betas/BetaDetails.test.tsx | 27 +- .../src/mocks/presets/baseline/noMocks.ts | 4 +- .../presets/extra/account/childAccount.ts | 49 --- .../presets/extra/account/customAccount.ts | 33 ++ .../presets/extra/account/customProfile.ts | 33 ++ .../extra/account/lkeEnterpriseEnabled.ts | 33 -- .../presets/extra/account/parentAccount.ts | 52 --- packages/manager/src/mocks/presets/index.ts | 10 +- packages/manager/src/mocks/types.ts | 18 +- 28 files changed, 1292 insertions(+), 340 deletions(-) create mode 100644 packages/manager/.changeset/pr-11328-tech-stories-1732650627657.md rename packages/manager/src/dev-tools/{dev-tools.tsx => DevTools.tsx} (100%) create mode 100644 packages/manager/src/dev-tools/components/ExtraPresetAccount.tsx create mode 100644 packages/manager/src/dev-tools/components/ExtraPresetProfile.tsx create mode 100644 packages/manager/src/dev-tools/components/JsonTextArea.tsx delete mode 100644 packages/manager/src/mocks/presets/extra/account/childAccount.ts create mode 100644 packages/manager/src/mocks/presets/extra/account/customAccount.ts create mode 100644 packages/manager/src/mocks/presets/extra/account/customProfile.ts delete mode 100644 packages/manager/src/mocks/presets/extra/account/lkeEnterpriseEnabled.ts delete mode 100644 packages/manager/src/mocks/presets/extra/account/parentAccount.ts diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 6bd9ee37d3f..d23ded3eb0d 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -59,32 +59,35 @@ export interface Account { export type BillingSource = 'linode' | 'akamai'; -export type AccountCapability = - | 'Akamai Cloud Load Balancer' - | 'Akamai Cloud Pulse' - | 'Block Storage' - | 'Block Storage Encryption' - | 'Cloud Firewall' - | 'CloudPulse' - | 'Disk Encryption' - | 'Kubernetes' - | 'Kubernetes Enterprise' - | 'Linodes' - | 'LKE HA Control Planes' - | 'LKE Network Access Control List (IP ACL)' - | 'Machine Images' - | 'Managed Databases' - | 'Managed Databases Beta' - | 'NETINT Quadra T1U' - | 'NodeBalancers' - | 'Object Storage Access Key Regions' - | 'Object Storage Endpoint Types' - | 'Object Storage' - | 'Placement Group' - | 'SMTP Enabled' - | 'Support Ticket Severity' - | 'Vlans' - | 'VPCs'; +export const accountCapabilities = [ + 'Akamai Cloud Load Balancer', + 'Akamai Cloud Pulse', + 'Block Storage', + 'Block Storage Encryption', + 'Cloud Firewall', + 'CloudPulse', + 'Disk Encryption', + 'Kubernetes', + 'Kubernetes Enterprise', + 'Linodes', + 'LKE HA Control Planes', + 'LKE Network Access Control List (IP ACL)', + 'Machine Images', + 'Managed Databases', + 'Managed Databases Beta', + 'NETINT Quadra T1U', + 'NodeBalancers', + 'Object Storage Access Key Regions', + 'Object Storage Endpoint Types', + 'Object Storage', + 'Placement Group', + 'SMTP Enabled', + 'Support Ticket Severity', + 'Vlans', + 'VPCs', +] as const; + +export type AccountCapability = typeof accountCapabilities[number]; export interface AccountAvailability { region: string; // will be slug of dc (matches id field of region object returned by API) diff --git a/packages/manager/.changeset/pr-11328-tech-stories-1732650627657.md b/packages/manager/.changeset/pr-11328-tech-stories-1732650627657.md new file mode 100644 index 00000000000..cb0547e3635 --- /dev/null +++ b/packages/manager/.changeset/pr-11328-tech-stories-1732650627657.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Dev Tools fixes and improvements ([#11328](https://github.com/linode/manager/pull/11328)) diff --git a/packages/manager/src/dev-tools/dev-tools.tsx b/packages/manager/src/dev-tools/DevTools.tsx similarity index 100% rename from packages/manager/src/dev-tools/dev-tools.tsx rename to packages/manager/src/dev-tools/DevTools.tsx diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 40bb55f6c11..a11cf2ab320 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -39,28 +39,85 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'iam', label: 'Identity and Access Beta' }, ]; +interface RenderFlagItemProps { + label: string; + onCheck: (e: React.ChangeEvent, flag: string) => void; + path: string; + searchTerm: string; + value: boolean | object | string | undefined; +} + +const renderFlagItem = ({ + label, + onCheck, + path = '', + searchTerm, + value, +}: RenderFlagItemProps) => { + const isObject = typeof value === 'object' && value !== null; + + if (!isObject) { + return ( + + ); + } + + const sortedEntries = Object.entries(value).sort((a, b) => + a[0].localeCompare(b[0]) + ); + + return ( +
    +
    + {label} + {sortedEntries.map(([key, nestedValue], index) => ( +
  • + {renderFlagItem({ + label: key, + onCheck, + path: path ? `${path}.${key}` : key, + searchTerm, + value: nestedValue, + })} +
  • + ))} +
    +
+ ); +}; + const renderFlagItems = ( flags: Partial, - onCheck: (e: React.ChangeEvent, flag: string) => void + onCheck: (e: React.ChangeEvent, flag: string) => void, + searchTerm: string ) => { - return options.map((option) => { + const sortedOptions = options.sort((a, b) => a.label.localeCompare(b.label)); + return sortedOptions.map((option) => { const flagValue = flags[option.flag]; - const isChecked = - typeof flagValue === 'object' && 'enabled' in flagValue - ? Boolean(flagValue.enabled) - : Boolean(flagValue); + const isSearchMatch = option.label + .toLowerCase() + .includes(searchTerm.toLowerCase()); + + if (!isSearchMatch) { + return null; + } return (
  • - onCheck(e, option.flag)} - type="checkbox" - /> - {option.label} + {renderFlagItem({ + label: option.label, + onCheck, + path: option.flag, + searchTerm, + value: flagValue, + })}
  • ); }); @@ -70,6 +127,7 @@ export const FeatureFlagTool = withFeatureFlagProvider(() => { const dispatch: Dispatch = useDispatch(); const flags = useFlags(); const ldFlags = ldUseFlags(); + const [searchTerm, setSearchTerm] = React.useState(''); React.useEffect(() => { const storedFlags = getStorage(MOCK_FEATURE_FLAGS_STORAGE_KEY); @@ -82,15 +140,33 @@ export const FeatureFlagTool = withFeatureFlagProvider(() => { e: React.ChangeEvent, flag: keyof FlagSet ) => { - const currentFlag = flags[flag]; - const updatedValue = - typeof currentFlag == 'object' && 'enabled' in currentFlag - ? { ...currentFlag, enabled: e.target.checked } // If current flag is an object, update 'enabled' key - : e.target.checked; - const updatedFlags = { - ...getStorage(MOCK_FEATURE_FLAGS_STORAGE_KEY), - [flag]: updatedValue, - }; + const updatedValue = e.target.checked; + const storedFlags = getStorage(MOCK_FEATURE_FLAGS_STORAGE_KEY) || {}; + + const flagParts = flag.split('.'); + const updatedFlags = { ...storedFlags }; + + // If the flag is not a nested flag, update it directly + if (flagParts.length === 1) { + updatedFlags[flag] = updatedValue; + } else { + // If the flag is a nested flag, update the specific property that changed + const [parentKey, childKey] = flagParts; + const currentParentValue = ldFlags[parentKey]; + const existingValues = storedFlags[parentKey] || {}; + + // Only update the specific property that changed + updatedFlags[parentKey] = { + ...currentParentValue, // Keep original LD values + ...existingValues, // Apply any existing stored overrides + [childKey]: updatedValue, // Apply the new change + }; + } + + updateFlagStorage(updatedFlags); + }; + + const updateFlagStorage = (updatedFlags: object) => { dispatch(setMockFeatureFlags(updatedFlags)); setStorage(MOCK_FEATURE_FLAGS_STORAGE_KEY, JSON.stringify(updatedFlags)); }; @@ -103,6 +179,10 @@ export const FeatureFlagTool = withFeatureFlagProvider(() => { setStorage(MOCK_FEATURE_FLAGS_STORAGE_KEY, ''); }; + const handleSearch = (e: React.ChangeEvent) => { + setSearchTerm(e.target.value); + }; + return (
    @@ -112,7 +192,13 @@ export const FeatureFlagTool = withFeatureFlagProvider(() => {
    -
      {renderFlagItems(flags, handleCheck)}
    + +
      {renderFlagItems(flags, handleCheck, searchTerm)}
    diff --git a/packages/manager/src/dev-tools/ServiceWorkerTool.tsx b/packages/manager/src/dev-tools/ServiceWorkerTool.tsx index 69a64bc27ef..4d8cf1250af 100644 --- a/packages/manager/src/dev-tools/ServiceWorkerTool.tsx +++ b/packages/manager/src/dev-tools/ServiceWorkerTool.tsx @@ -1,4 +1,3 @@ -import { Tooltip } from '@linode/ui'; import * as React from 'react'; import { mswDB } from 'src/mocks/indexedDB'; @@ -12,12 +11,16 @@ import { ExtraPresetOptions } from './components/ExtraPresetOptions'; import { SeedOptions } from './components/SeedOptions'; import { getBaselinePreset, + getCustomAccountData, + getCustomProfileData, getExtraPresets, getExtraPresetsMap, getSeeders, getSeedsCountMap, isMSWEnabled, saveBaselinePreset, + saveCustomAccountData, + saveCustomProfileData, saveExtraPresets, saveExtraPresetsMap, saveMSWEnabled, @@ -25,6 +28,7 @@ import { saveSeedsCountMap, } from './utils'; +import type { Account, Profile } from '@linode/api-v4'; import type { MockPresetBaselineId, MockPresetCrudId, @@ -55,6 +59,12 @@ export const ServiceWorkerTool = () => { const [extraPresets, setExtraPresets] = React.useState( loadedExtraPresets ); + const [customAccountData, setCustomAccountData] = React.useState< + Account | null | undefined + >(getCustomAccountData()); + const [customProfileData, setCustomProfileData] = React.useState< + Profile | null | undefined + >(getCustomProfileData()); const [presetsCountMap, setPresetsCountMap] = React.useState<{ [key: string]: number; }>(loadedPresetsMap); @@ -62,6 +72,7 @@ export const ServiceWorkerTool = () => { const [seedsCountMap, setSeedsCountMap] = React.useState<{ [key: string]: number; }>(loadedSeedsCountMap); + const [mswEnabled, setMswEnabled] = React.useState(isMSWEnabled); const isCrudPreset = loadedBaselinePreset === 'baseline:crud' || baselinePreset === 'baseline:crud'; @@ -72,6 +83,22 @@ export const ServiceWorkerTool = () => { mocksCleared: false, }); + React.useEffect(() => { + const currentAccountData = getCustomAccountData(); + const currentProfileData = getCustomProfileData(); + const hasCustomAccountChanges = + JSON.stringify(currentAccountData) !== JSON.stringify(customAccountData); + const hasCustomProfileChanges = + JSON.stringify(currentProfileData) !== JSON.stringify(customProfileData); + + if (hasCustomAccountChanges || hasCustomProfileChanges) { + setSaveState((prev) => ({ + ...prev, + hasUnsavedChanges: true, + })); + } + }, [customAccountData, customProfileData]); + const globalHandlers = { applyChanges: () => { // Save base preset, extra presets, and content seeders to local storage. @@ -81,6 +108,14 @@ export const ServiceWorkerTool = () => { saveSeedsCountMap(seedsCountMap); saveExtraPresetsMap(presetsCountMap); + if (extraPresets.includes('account:custom') && customAccountData) { + saveCustomAccountData(customAccountData); + } + + if (extraPresets.includes('profile:custom') && customProfileData) { + saveCustomProfileData(customProfileData); + } + const promises = seeders.map((seederId) => { const seeder = dbSeeders.find((dbSeeder) => dbSeeder.id === seederId); @@ -95,11 +130,7 @@ export const ServiceWorkerTool = () => { })); }); - // We only have to reload the window if MSW is already enabled. Otherwise, - // the changes will automatically be picked up next time MSW is enabled. - if (isMSWEnabled) { - window.location.reload(); - } + window.location.reload(); }, discardChanges: () => { @@ -108,6 +139,8 @@ export const ServiceWorkerTool = () => { setSeeders(getSeeders(dbSeeders)); setSeedsCountMap(getSeedsCountMap()); setPresetsCountMap(getExtraPresetsMap()); + setCustomAccountData(getCustomAccountData()); + setCustomProfileData(getCustomProfileData()); setSaveState({ hasSaved: false, hasUnsavedChanges: false, @@ -118,15 +151,18 @@ export const ServiceWorkerTool = () => { mswDB.clear('mockState'); mswDB.clear('seedState'); seederHandlers.removeAll(); - setBaselinePreset('baseline:preset-mocking'); + setBaselinePreset('baseline:static-mocking'); setExtraPresets([]); setPresetsCountMap({}); - saveBaselinePreset('baseline:preset-mocking'); + setCustomAccountData(null); + setCustomProfileData(null); + saveBaselinePreset('baseline:static-mocking'); saveExtraPresets([]); saveSeeders([]); saveSeedsCountMap({}); saveExtraPresetsMap({}); - + saveCustomAccountData(null); + saveCustomProfileData(null); setSaveState({ hasSaved: false, hasUnsavedChanges: true, @@ -136,7 +172,11 @@ export const ServiceWorkerTool = () => { toggleMSW: (e: React.ChangeEvent) => { saveMSWEnabled(e.target.checked); - window.location.reload(); + setMswEnabled(e.target.checked); + setSaveState({ + hasSaved: false, + hasUnsavedChanges: true, + }); }, }; @@ -270,88 +310,83 @@ export const ServiceWorkerTool = () => {
    API Mocks
    - -
    -
    -
    + +
    +
    +
    +
    -
    - Base Preset + Enable MSW - presetHandlers.changeBase(e)} - value={baselinePreset} - > - - -
    +
    -
    -
    -
    + Base Preset + presetHandlers.changeBase(e)} + value={baselinePreset} + > + + +
    +
    +
    +
    +
    + Seeds (CRUD preset only) + -
    -
    -
    - -
    + Remove all seeds + +
    +
    +
    +
    -
    -
    Presets
    -
    -
    - -
    +
    +
    +
    Presets
    +
    +
    +
    - +
    +
    + )} +
    + {isEnabled && isEditingCustomAccount && ( + setIsEditingCustomAccount(false)} + open={isEditingCustomAccount} + title="Edit Custom Account" + > +
    { + e.preventDefault(); + setIsEditingCustomAccount(false); + }} + className="dev-tools__modal-form" + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + )} + + ); +}; + +const FieldWrapper = ({ children }: { children: React.ReactNode }) => { + return
    {children}
    ; +}; diff --git a/packages/manager/src/dev-tools/components/ExtraPresetOptionCheckbox.tsx b/packages/manager/src/dev-tools/components/ExtraPresetOptionCheckbox.tsx index 9eedf8d709b..60dc6c8a22c 100644 --- a/packages/manager/src/dev-tools/components/ExtraPresetOptionCheckbox.tsx +++ b/packages/manager/src/dev-tools/components/ExtraPresetOptionCheckbox.tsx @@ -13,7 +13,6 @@ export const ExtraPresetOptionCheckbox = ( props: ExtraPresetOptionCheckboxProps ) => { const { - disabled, group, handlers, onPresetCountChange, @@ -32,18 +31,14 @@ export const ExtraPresetOptionCheckbox = ( style={{ display: 'flex', justifyContent: 'space-between' }} >
    - onTogglePreset(e, extraMockPreset.id)} - type="checkbox" - /> - + +
    {extraMockPreset.canUpdateCount && (
    { - const { disabled, group, handlers, onSelectChange } = props; + const { group, handlers, onSelectChange } = props; return (
    @@ -28,7 +28,6 @@ export const ExtraPresetOptionSelect = ( ) || '' } className="dev-tools__select thin" - disabled={disabled} onChange={(e) => onSelectChange(e, group)} style={{ width: 125 }} > diff --git a/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx b/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx index 6a56f0f650c..333fba7f4e5 100644 --- a/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx +++ b/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx @@ -3,12 +3,19 @@ import * as React from 'react'; import { getMockPresetGroups } from 'src/mocks/mockPreset'; import { extraMockPresets } from 'src/mocks/presets'; +import { ExtraPresetAccount } from './ExtraPresetAccount'; import { ExtraPresetOptionCheckbox } from './ExtraPresetOptionCheckbox'; import { ExtraPresetOptionSelect } from './ExtraPresetOptionSelect'; +import { ExtraPresetProfile } from './ExtraPresetProfile'; + +import type { Account, Profile } from '@linode/api-v4'; export interface ExtraPresetOptionsProps { - disabled: boolean; + customAccountData?: Account | null; + customProfileData?: Profile | null; handlers: string[]; + onCustomAccountChange?: (data: Account | null | undefined) => void; + onCustomProfileChange?: (data: Profile | null | undefined) => void; onPresetCountChange: (e: React.ChangeEvent, presetId: string) => void; onSelectChange: (e: React.ChangeEvent, presetId: string) => void; onTogglePreset: (e: React.ChangeEvent, presetId: string) => void; @@ -19,8 +26,11 @@ export interface ExtraPresetOptionsProps { * Renders a list of extra presets with an optional count. */ export const ExtraPresetOptions = ({ - disabled, + customAccountData, + customProfileData, handlers, + onCustomAccountChange, + onCustomProfileChange, onPresetCountChange, onSelectChange, onTogglePreset, @@ -46,7 +56,6 @@ export const ExtraPresetOptions = ({ {group}{' '} {currentGroupType === 'select' && ( {currentGroupType === 'checkbox' && ( )} + {currentGroupType === 'account' && ( + + )} + {currentGroupType === 'profile' && ( + + )}
    ); })} diff --git a/packages/manager/src/dev-tools/components/ExtraPresetProfile.tsx b/packages/manager/src/dev-tools/components/ExtraPresetProfile.tsx new file mode 100644 index 00000000000..6701c5f3d3e --- /dev/null +++ b/packages/manager/src/dev-tools/components/ExtraPresetProfile.tsx @@ -0,0 +1,310 @@ +import * as React from 'react'; + +import { Dialog } from 'src/components/Dialog/Dialog'; +import { profileFactory } from 'src/factories'; +import { extraMockPresets } from 'src/mocks/presets'; +import { setCustomProfileData } from 'src/mocks/presets/extra/account/customProfile'; + +import { saveCustomProfileData } from '../utils'; +import { JsonTextArea } from './JsonTextArea'; + +import type { Profile } from '@linode/api-v4'; + +const profilePreset = extraMockPresets.find((p) => p.id === 'profile:custom'); + +interface ExtraPresetProfileProps { + customProfileData: Profile | null | undefined; + handlers: string[]; + onFormChange?: (data: Profile | null | undefined) => void; + onTogglePreset: ( + e: React.ChangeEvent, + presetId: string + ) => void; +} + +export const ExtraPresetProfile = ({ + customProfileData, + handlers, + onFormChange, + onTogglePreset, +}: ExtraPresetProfileProps) => { + const isEnabled = handlers.includes('profile:custom'); + const [formData, setFormData] = React.useState(() => ({ + ...profileFactory.build({ + restricted: false, + }), + ...customProfileData, + })); + const [isEditingCustomProfile, setIsEditingCustomProfile] = React.useState( + false + ); + + const handleInputChange = ( + e: React.ChangeEvent< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + > + ) => { + // radios + const { name, value } = e.target; + const isRadioToggleField = [ + 'email_notifications', + 'restricted', + 'two_factor_auth', + ].includes(name); + + const newValue = isRadioToggleField ? value === 'true' : value; + const newFormData = { + ...formData, + [name]: newValue, + }; + + setFormData(newFormData); + + if (isEnabled) { + onFormChange?.(newFormData); + } + }; + + const handleTogglePreset = (e: React.ChangeEvent) => { + if (!e.target.checked) { + saveCustomProfileData(null); + } else { + saveCustomProfileData(formData); + } + onTogglePreset(e, 'profile:custom'); + }; + + React.useEffect(() => { + if (!isEnabled) { + setFormData({ + ...profileFactory.build(), + }); + setCustomProfileData(null); + } else if (isEnabled && customProfileData) { + setFormData((prev) => ({ + ...prev, + ...customProfileData, + })); + setCustomProfileData(customProfileData); + } + }, [isEnabled, customProfileData]); + + if (!profilePreset) { + return null; + } + + return ( +
  • +
    +
    + +
    + {isEnabled && ( +
    + +
    + )} +
    + {isEnabled && isEditingCustomProfile && ( + setIsEditingCustomProfile(false)} + open={isEditingCustomProfile} + title="Edit Custom Profile" + > +
    setIsEditingCustomProfile(false)} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + )} +
  • + ); +}; + +const FieldWrapper = ({ children }: { children: React.ReactNode }) => { + return
    {children}
    ; +}; diff --git a/packages/manager/src/dev-tools/components/JsonTextArea.tsx b/packages/manager/src/dev-tools/components/JsonTextArea.tsx new file mode 100644 index 00000000000..dbccbe44fce --- /dev/null +++ b/packages/manager/src/dev-tools/components/JsonTextArea.tsx @@ -0,0 +1,91 @@ +import * as React from 'react'; + +interface JsonTextAreaProps { + height?: number; + label?: string; + name: string; + onChange: ( + e: React.ChangeEvent< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + > + ) => void; + value: unknown; +} + +export const JsonTextArea = ({ + height, + label, + name, + onChange, + value, +}: JsonTextAreaProps) => { + const [rawText, setRawText] = React.useState(JSON.stringify(value, null, 2)); + + const debouncedUpdate = React.useMemo( + () => + debounce((text: string) => { + try { + const parsedJson = JSON.parse(text); + const event = { + currentTarget: { + name, + value: parsedJson, + }, + target: { + name, + value: parsedJson, + }, + } as React.ChangeEvent; + + onChange(event); + } catch (err) { + // Only warn if the text isn't empty and isn't in the middle of editing + if (text.trim()) { + // eslint-disable-next-line no-console + console.warn(`Invalid JSON in ${name}, error: ${err}`); + } + } + }, 500), + [name, onChange] + ); + + React.useEffect(() => { + const newText = JSON.stringify(value, null, 2); + if (newText !== rawText) { + setRawText(newText); + } + }, [value]); + + const handleChange = (e: React.ChangeEvent) => { + const newText = e.target.value; + setRawText(newText); + debouncedUpdate(newText); + }; + + return ( +