React Hook Form: Update to v 7.49.2 (#79493)

* Update RHF to latest

* Update Form types

* Fix alerting types

* Fix correlations types

* Update tests

* Fix tests

* Update LabelsField.tsx to use InputControl

* Update RuleEditorGrafanaRules.test.tsx

* Update RuleEditorCloudRules.test.tsx

* Only require one label

* Update RuleEditorRecordingRule.test.tsx

* Fix labels rules

* Revert

* Remove RHF from ignore rules

* Revert

* update form validation for overriding group timings

* Fix changes to correlations

* Fix auth type errors

---------

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
Co-authored-by: Kristina Durivage <kristina.durivage@grafana.com>
This commit is contained in:
Alex Khomenko 2024-01-05 12:41:49 +02:00 committed by GitHub
parent 3537c5440f
commit 99f7110e39
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 147 additions and 107 deletions

View File

@ -378,7 +378,7 @@
"react-dropzone": "^14.2.3",
"react-grid-layout": "1.4.2",
"react-highlight-words": "0.20.0",
"react-hook-form": "7.5.3",
"react-hook-form": "^7.49.2",
"react-i18next": "^12.0.0",
"react-inlinesvg": "3.0.2",
"react-loading-skeleton": "3.3.1",

View File

@ -92,7 +92,7 @@
"react-custom-scrollbars-2": "4.5.0",
"react-dropzone": "14.2.3",
"react-highlight-words": "0.20.0",
"react-hook-form": "7.5.3",
"react-hook-form": "^7.49.2",
"react-i18next": "^12.0.0",
"react-inlinesvg": "3.0.2",
"react-loading-skeleton": "3.3.1",

View File

@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import React, { HTMLProps, useEffect } from 'react';
import { useForm, Mode, DeepPartial, UnpackNestedValue, SubmitHandler, FieldValues } from 'react-hook-form';
import { useForm, Mode, DefaultValues, SubmitHandler, FieldValues } from 'react-hook-form';
import { FormAPI } from '../../types';
@ -8,7 +8,7 @@ interface FormProps<T extends FieldValues> extends Omit<HTMLProps<HTMLFormElemen
validateOn?: Mode;
validateOnMount?: boolean;
validateFieldsOnMount?: string | string[];
defaultValues?: UnpackNestedValue<DeepPartial<T>>;
defaultValues?: DefaultValues<T>;
onSubmit: SubmitHandler<T>;
children: (api: FormAPI<T>) => React.ReactNode;
/** Sets max-width for container. Use it instead of setting individual widths on inputs.*/

View File

@ -58,7 +58,7 @@ export const NotificationChannelOptions = ({
label={option.label}
description={option.description}
invalid={errors.settings && !!errors.settings[option.propertyName]}
error={errors.settings && errors.settings[option.propertyName]?.message}
error={errors.settings && String(errors.settings[option.propertyName]?.message || '')}
>
{secureFields && secureFields[option.propertyName] ? (
<Input

View File

@ -14,12 +14,12 @@ import { TimezoneSelect } from './timezones';
export const MuteTimingTimeInterval = () => {
const styles = useStyles2(getStyles);
const { formState, register, setValue } = useFormContext();
const { formState, register, setValue } = useFormContext<MuteTimingFields>();
const {
fields: timeIntervals,
append: addTimeInterval,
remove: removeTimeInterval,
} = useFieldArray<MuteTimingFields>({
} = useFieldArray({
name: 'time_intervals',
});
@ -43,7 +43,11 @@ export const MuteTimingTimeInterval = () => {
return (
<div key={timeInterval.id} className={styles.timeIntervalSection}>
<MuteTimingTimeRange intervalIndex={timeIntervalIndex} />
<Field label="Location" invalid={Boolean(errors.location)} error={errors.location?.message}>
<Field
label="Location"
invalid={Boolean(errors.time_intervals?.[timeIntervalIndex]?.location)}
error={errors.time_intervals?.[timeIntervalIndex]?.location?.message}
>
<TimezoneSelect
prefix={<Icon name="map-marker" />}
width={50}

View File

@ -28,7 +28,7 @@ export const MuteTimingTimeRange = ({ intervalIndex }: Props) => {
});
const formErrors = formState.errors.time_intervals?.[intervalIndex];
const timeRangeInvalid = formErrors?.times?.some((value) => value?.start_time || value?.end_time) ?? false;
const timeRangeInvalid = formErrors?.times?.some?.((value) => value?.start_time || value?.end_time) ?? false;
return (
<div>

View File

@ -258,7 +258,7 @@ export const AmRoutesExpandedForm = ({
>
<PromDurationInput
{...register('repeatIntervalValue', {
validate: (value: string) => {
validate: (value = '') => {
const groupInterval = getValues('groupIntervalValue');
return repeatIntervalValidator(value, groupInterval);
},

View File

@ -147,7 +147,7 @@ export const TemplateForm = ({ existing, alertManagerSourceName, config, provena
watch,
} = formApi;
const validateNameIsUnique: Validate<string> = (name: string) => {
const validateNameIsUnique: Validate<string, TemplateFormValues> = (name: string) => {
return !config.template_files[name] || existing?.name === name
? true
: 'Another template with this name already exists.';

View File

@ -69,7 +69,8 @@ export function ChannelSubForm<R extends ChannelValues>({
// Prevent forgetting about initial values when switching the integration type and the oncall integration type
useEffect(() => {
// Restore values when switching back from a changed integration to the default one
const subscription = watch((_, { name, type, value }) => {
const subscription = watch((v, { name, type }) => {
const value = name ? v[name] : '';
if (initialValues && name === fieldName('type') && value === initialValues.type && type === 'change') {
setValue(fieldName('settings'), initialValues.settings);
}

View File

@ -83,7 +83,7 @@ export function ReceiverForm<R extends ChannelValues>({
const { fields, append, remove } = useControlledFieldArray<R>({ name: 'items', formAPI, softDelete: true });
const validateNameIsAvailable: Validate<string> = useCallback(
const validateNameIsAvailable: Validate<string, ReceiverFormValues<R>> = useCallback(
(name: string) =>
takenReceiverNames.map((name) => name.trim().toLowerCase()).includes(name.trim().toLowerCase())
? 'Another receiver with this name already exists.'

View File

@ -1,9 +1,19 @@
import { css, cx } from '@emotion/css';
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FieldArrayMethodProps, useFieldArray, useFormContext } from 'react-hook-form';
import { useFieldArray, UseFieldArrayAppend, useFormContext } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Button, Field, InlineLabel, Input, LoadingPlaceholder, Stack, Text, useStyles2 } from '@grafana/ui';
import {
Button,
Field,
InlineLabel,
Input,
InputControl,
LoadingPlaceholder,
Stack,
Text,
useStyles2,
} from '@grafana/ui';
import { useDispatch } from 'app/types';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
@ -85,10 +95,7 @@ const RemoveButton: FC<{
);
const AddButton: FC<{
append: (
value: Partial<{ key: string; value: string }> | Array<Partial<{ key: string; value: string }>>,
options?: FieldArrayMethodProps | undefined
) => void;
append: UseFieldArrayAppend<RuleFormValues, 'labels'>;
className: string;
}> = ({ append, className }) => (
<Button
@ -107,11 +114,9 @@ const AddButton: FC<{
const LabelsWithSuggestions: FC<{ dataSourceName: string }> = ({ dataSourceName }) => {
const styles = useStyles2(getStyles);
const {
register,
control,
watch,
formState: { errors },
setValue,
} = useFormContext<RuleFormValues>();
const labels = watch('labels');
@ -151,17 +156,24 @@ const LabelsWithSuggestions: FC<{ dataSourceName: string }> = ({ dataSourceName
error={errors.labels?.[index]?.key?.message}
data-testid={`label-key-${index}`}
>
<AlertLabelDropdown
{...register(`labels.${index}.key`, {
required: { value: Boolean(labels[index]?.value), message: 'Required.' },
})}
defaultValue={field.key ? { label: field.key, value: field.key } : undefined}
options={keys}
onChange={(newValue: SelectableValue) => {
setValue(`labels.${index}.key`, newValue.value);
setSelectedKey(newValue.value);
<InputControl
name={`labels.${index}.key`}
control={control}
rules={{ required: Boolean(labels[index]?.value) ? 'Required.' : false }}
render={({ field: { onChange, ref, ...rest } }) => {
return (
<AlertLabelDropdown
{...rest}
defaultValue={field.key ? { label: field.key, value: field.key } : undefined}
options={keys}
onChange={(newValue: SelectableValue) => {
onChange(newValue.value);
setSelectedKey(newValue.value);
}}
type="key"
/>
);
}}
type="key"
/>
</Field>
<InlineLabel className={styles.equalSign}>=</InlineLabel>
@ -171,19 +183,26 @@ const LabelsWithSuggestions: FC<{ dataSourceName: string }> = ({ dataSourceName
error={errors.labels?.[index]?.value?.message}
data-testid={`label-value-${index}`}
>
<AlertLabelDropdown
{...register(`labels.${index}.value`, {
required: { value: Boolean(labels[index]?.key), message: 'Required.' },
})}
defaultValue={field.value ? { label: field.value, value: field.value } : undefined}
options={values}
onChange={(newValue: SelectableValue) => {
setValue(`labels.${index}.value`, newValue.value);
<InputControl
control={control}
name={`labels.${index}.value`}
rules={{ required: Boolean(labels[index]?.value) ? 'Required.' : false }}
render={({ field: { onChange, ref, ...rest } }) => {
return (
<AlertLabelDropdown
{...rest}
defaultValue={field.value ? { label: field.value, value: field.value } : undefined}
options={values}
onChange={(newValue: SelectableValue) => {
onChange(newValue.value);
}}
onOpenMenu={() => {
setSelectedKey(labels[index].key);
}}
type="value"
/>
);
}}
onOpenMenu={() => {
setSelectedKey(labels[index].key);
}}
type="value"
/>
</Field>
@ -268,7 +287,7 @@ const LabelsField: FC<Props> = ({ dataSourceName }) => {
Add labels to your rule to annotate your rules, ease searching, or route to a notification policy.
</Text>
<NeedHelpInfo
contentText="The dropdown only displays labels that you have previously used for alerts.
contentText="The dropdown only displays labels that you have previously used for alerts.
Select a label from the options below or type in a new one."
title="Labels"
/>

View File

@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import React, { useEffect, useMemo, useState } from 'react';
import { DeepMap, FieldError, FormProvider, useForm, UseFormWatch } from 'react-hook-form';
import { FormProvider, SubmitErrorHandler, useForm, UseFormWatch } from 'react-hook-form';
import { Link, useParams } from 'react-router-dom';
import { GrafanaTheme2 } from '@grafana/data';
@ -144,7 +144,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
}
};
const onInvalid = (errors: DeepMap<RuleFormValues, FieldError>): void => {
const onInvalid: SubmitErrorHandler<RuleFormValues> = (errors): void => {
if (!existing) {
trackNewAlerRuleFormError({
grafana_version: config.buildInfo.version,

View File

@ -6,7 +6,7 @@ import { CloudNotifierType, NotifierType } from 'app/types';
import { ControlledField } from '../hooks/useControlledFieldArray';
export interface ChannelValues {
__id: string; // used to correllate form values to original DTOs
__id: string; // used to correlate form values to original DTOs
type: string;
settings: Record<string, any>;
secureSettings: Record<string, any>;

View File

@ -221,8 +221,8 @@ export const mapMultiSelectValueToStrings = (
return selectableValuesToStrings(selectableValues);
};
export function promDurationValidator(duration: string) {
if (duration.length === 0) {
export function promDurationValidator(duration?: string) {
if (!duration || duration.length === 0) {
return true;
}
@ -237,7 +237,7 @@ export const objectMatchersToString = (matchers: ObjectMatcher[]): string[] => {
});
};
export const repeatIntervalValidator = (repeatInterval: string, groupInterval: string) => {
export const repeatIntervalValidator = (repeatInterval: string, groupInterval = '') => {
if (repeatInterval.length === 0) {
return true;
}

View File

@ -1,4 +1,5 @@
import { FieldData, SSOProvider } from './types';
import { isSelectableValue } from './utils/guards';
/** Map providers to their settings */
export const fields: Record<SSOProvider['provider'], Array<keyof SSOProvider['settings']>> = {
@ -46,7 +47,10 @@ export const fieldMap: Record<string, FieldData> = {
if (typeof value === 'string') {
return isNumeric(value);
}
return value.every((v) => v?.value && isNumeric(v.value));
if (isSelectableValue(value)) {
return value.every((v) => v?.value && isNumeric(v.value));
}
return true;
},
message: 'Team ID must be a number.',
},

View File

@ -1,3 +1,5 @@
import { Validate } from 'react-hook-form';
import { IconName, SelectableValue } from '@grafana/data';
import { Settings } from 'app/types';
@ -26,7 +28,7 @@ export type SSOProviderSettingsBase = {
emailAttributePath?: string;
emptyScopes?: boolean;
enabled: boolean;
extra?: Record<string, unknown>;
extra?: Record<string, string>;
groupsAttributePath?: string;
hostedDomain?: string;
icon?: IconName;
@ -97,7 +99,7 @@ export type FieldData = {
validation?: {
required?: boolean;
message?: string;
validate?: (value: string | Array<SelectableValue<string>>) => boolean | string | Promise<boolean | string>;
validate?: Validate<SSOProviderDTO[keyof SSOProviderDTO], SSOProviderDTO>;
};
multi?: boolean;
allowCustomValue?: boolean;

View File

@ -538,7 +538,7 @@ describe('CorrelationsPage', () => {
// select Regex, be sure expression field is not disabled and contains the former expression
openMenu(typeFilterSelect[0]);
await userEvent.click(screen.getByText('Regular expression', { selector: 'span' }));
await userEvent.click(screen.getByText('Regular expression'));
expressionInput = screen.queryByLabelText(/expression/i);
expect(expressionInput).toBeInTheDocument();
expect(expressionInput).toBeEnabled();
@ -554,7 +554,8 @@ describe('CorrelationsPage', () => {
await userEvent.click(screen.getByRole('button', { name: /add transformation/i }));
typeFilterSelect = screen.getAllByLabelText('Type');
openMenu(typeFilterSelect[0]);
await userEvent.click(screen.getByText('Regular expression'));
const menu = await screen.findByLabelText('Select options menu');
await userEvent.click(within(menu).getByText('Regular expression'));
expressionInput = screen.queryByLabelText(/expression/i);
expect(expressionInput).toBeInTheDocument();
expect(expressionInput).toBeEnabled();

View File

@ -12,6 +12,7 @@ import { getVariableUsageInfo } from '../../explore/utils/links';
import { TransformationsEditor } from './TransformationsEditor';
import { useCorrelationsFormContext } from './correlationsFormContext';
import { FormDTO } from './types';
import { getInputId } from './utils';
const getStyles = (theme: GrafanaTheme2) => ({
@ -25,7 +26,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
});
export const ConfigureCorrelationSourceForm = () => {
const { control, formState, register, getValues } = useFormContext();
const { control, formState, register, getValues } = useFormContext<FormDTO>();
const styles = useStyles2(getStyles);
const withDsUID = (fn: Function) => (ds: DataSourceInstanceSettings) => fn(ds.uid);

View File

@ -8,9 +8,10 @@ import { DataSourcePicker } from 'app/features/datasources/components/picker/Dat
import { QueryEditorField } from './QueryEditorField';
import { useCorrelationsFormContext } from './correlationsFormContext';
import { FormDTO } from './types';
export const ConfigureCorrelationTargetForm = () => {
const { control, formState } = useFormContext();
const { control, formState } = useFormContext<FormDTO>();
const withDsUID = (fn: Function) => (ds: DataSourceInstanceSettings) => fn(ds.uid);
const { correlation } = useCorrelationsFormContext();
const targetUID: string | undefined = useWatch({ name: 'targetUID' }) || correlation?.targetUID;

View File

@ -5,7 +5,7 @@ import { useFormContext, useWatch } from 'react-hook-form';
import { Field, Icon, IconButton, Input, Label, Select, Stack, Tooltip, useStyles2 } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
import { getSupportedTransTypeDetails, getTransformOptions } from './types';
import { FormDTO, getSupportedTransTypeDetails, getTransformOptions } from './types';
type Props = {
index: number;
value: Record<string, string>;
@ -23,7 +23,7 @@ const getStyles = () => ({
const TransformationEditorRow = (props: Props) => {
const { index, value: defaultValue, readOnly, remove } = props;
const { control, formState, register, setValue, watch, getValues } = useFormContext();
const { control, formState, register, setValue, watch, getValues } = useFormContext<FormDTO>();
const [keptVals, setKeptVals] = useState<{ expression?: string; mapValue?: string }>({});
@ -63,34 +63,37 @@ const TransformationEditorRow = (props: Props) => {
</Stack>
}
invalid={!!formState.errors?.config?.transformations?.[index]?.type}
error={formState.errors?.config?.transformations?.[index]?.type?.message}
error={formState.errors?.config?.transformations?.[index]?.message}
validationMessageHorizontalOverflow={true}
>
<Select
value={typeValue}
onChange={(value) => {
if (!readOnly) {
const currentValues = getValues().config.transformations[index];
setKeptVals({
expression: currentValues.expression,
mapValue: currentValues.mapValue,
});
const newValueDetails = getSupportedTransTypeDetails(value.value);
if (newValueDetails.expressionDetails.show) {
setValue(`config.transformations.${index}.expression`, keptVals?.expression || '');
} else {
setValue(`config.transformations.${index}.expression`, '');
const currentValues = getValues()?.config?.transformations?.[index];
if (currentValues) {
setKeptVals({
expression: currentValues.expression,
mapValue: currentValues.mapValue,
});
}
if (value.value) {
const newValueDetails = getSupportedTransTypeDetails(value.value);
if (newValueDetails.mapValueDetails.show) {
setValue(`config.transformations.${index}.mapValue`, keptVals?.mapValue || '');
} else {
setValue(`config.transformations.${index}.mapValue`, '');
if (newValueDetails.expressionDetails.show) {
setValue(`config.transformations.${index}.expression`, keptVals?.expression || '');
} else {
setValue(`config.transformations.${index}.expression`, '');
}
if (newValueDetails.mapValueDetails.show) {
setValue(`config.transformations.${index}.mapValue`, keptVals?.mapValue || '');
} else {
setValue(`config.transformations.${index}.mapValue`, '');
}
setValue(`config.transformations.${index}.type`, value.value);
}
setValue(`config.transformations.${index}.type`, value.value);
}
}}
options={transformOptions}

View File

@ -1,11 +1,11 @@
import { ComponentType } from 'react';
import { DeepPartial, UnpackNestedValue } from 'react-hook-form';
import { DefaultValues } from 'react-hook-form';
export type WizardProps<T> = {
/**
* Initial values for the form
*/
defaultValues?: UnpackNestedValue<DeepPartial<T>>;
defaultValues?: DefaultValues<T>;
/**
* List of steps/pages in the wizard.

View File

@ -30,7 +30,7 @@ type CorrelationConfigType = 'query';
export interface CorrelationConfig {
field: string;
target: object;
target: object; // this contains anything that would go in the query editor, so any extension off DataQuery a datasource would have, and needs to be generic
type: CorrelationConfigType;
transformations?: DataLinkTransformationConfig[];
}

View File

@ -84,10 +84,10 @@ export const CorrelationHelper = ({ exploreId, correlations }: Props) => {
useEffect(() => {
const subscription = watch((value) => {
let dirty = correlationDetails?.correlationDirty || false;
if (!dirty && (value.label !== defaultLabel || value.description !== '')) {
let description = value.description || '';
if (!dirty && (value.label !== defaultLabel || description !== '')) {
dirty = true;
} else if (dirty && value.label === defaultLabel && value.description.trim() === '') {
} else if (dirty && value.label === defaultLabel && description.trim() === '') {
dirty = false;
}
dispatch(

View File

@ -101,18 +101,21 @@ export const CorrelationTransformationAddModal = ({
isExpressionValid = !formFieldsVis.expressionDetails.show;
}
setIsExpValid(isExpressionValid);
const transformationVars = getTransformationVars(
{
type: formValues.type,
expression: isExpressionValid ? expression : '',
mapValue: formValues.mapValue,
},
fieldList[formValues.field!] || '',
formValues.field!
);
let transKeys = [];
if (formValues.type) {
const transformationVars = getTransformationVars(
{
type: formValues.type,
expression: isExpressionValid ? expression : '',
mapValue: formValues.mapValue,
},
fieldList[formValues.field!] || '',
formValues.field!
);
const transKeys = Object.keys(transformationVars);
setTransformationVars(transKeys.length > 0 ? { ...transformationVars } : {});
transKeys = Object.keys(transformationVars);
setTransformationVars(transKeys.length > 0 ? { ...transformationVars } : {});
}
if (transKeys.length === 0 || !isExpressionValid) {
setValidToSave(false);

View File

@ -1,6 +1,6 @@
import { partial } from 'lodash';
import React, { type ReactElement, useEffect, useState } from 'react';
import { DeepMap, FieldError, useForm } from 'react-hook-form';
import { DeepMap, FieldError, FieldErrors, useForm } from 'react-hook-form';
import { locationUtil, SelectableValue } from '@grafana/data';
import { config, locationService, reportInteraction } from '@grafana/runtime';
@ -34,7 +34,7 @@ interface SaveToExistingDashboard extends SaveTargetDTO {
type FormDTO = SaveToNewDashboardDTO | SaveToExistingDashboard;
function assertIsSaveToExistingDashboardError(
errors: DeepMap<FormDTO, FieldError>
errors: FieldErrors<FormDTO>
): asserts errors is DeepMap<SaveToExistingDashboard, FieldError> {
// the shape of the errors object is always compatible with the type above, but we need to
// explicitly assert its type so that TS can narrow down FormDTO to SaveToExistingDashboard

View File

@ -299,8 +299,8 @@ const builtInVariables = [
* @param query
* @param scopedVars
*/
export function getVariableUsageInfo<T extends DataLink>(
query: T,
export function getVariableUsageInfo(
query: object,
scopedVars: ScopedVars
): { variables: VariableInterpolation[]; allVariablesDefined: boolean } {
let variables: VariableInterpolation[] = [];

View File

@ -39,6 +39,7 @@ jest.mock('app/core/core', () => ({
hasPermission: () => true,
hasPermissionInMetadata: () => true,
user: { orgId: 1 },
fetchUserPermissions: () => Promise.resolve(),
},
}));

View File

@ -8,7 +8,7 @@ type FormModel = { folderName: string };
interface Props {
onSubmit: SubmitHandler<FormModel>;
onDismiss: () => void;
validate: Validate<string>;
validate: Validate<string, FormModel>;
}
const initialFormModel = { folderName: '' };

View File

@ -3551,7 +3551,7 @@ __metadata:
react-dom: "npm:18.2.0"
react-dropzone: "npm:14.2.3"
react-highlight-words: "npm:0.20.0"
react-hook-form: "npm:7.5.3"
react-hook-form: "npm:^7.49.2"
react-i18next: "npm:^12.0.0"
react-inlinesvg: "npm:3.0.2"
react-loading-skeleton: "npm:3.3.1"
@ -17664,7 +17664,7 @@ __metadata:
react-dropzone: "npm:^14.2.3"
react-grid-layout: "npm:1.4.2"
react-highlight-words: "npm:0.20.0"
react-hook-form: "npm:7.5.3"
react-hook-form: "npm:^7.49.2"
react-i18next: "npm:^12.0.0"
react-inlinesvg: "npm:3.0.2"
react-loading-skeleton: "npm:3.3.1"
@ -25683,12 +25683,12 @@ __metadata:
languageName: node
linkType: hard
"react-hook-form@npm:7.5.3":
version: 7.5.3
resolution: "react-hook-form@npm:7.5.3"
"react-hook-form@npm:^7.49.2":
version: 7.49.2
resolution: "react-hook-form@npm:7.49.2"
peerDependencies:
react: ^16.8.0 || ^17
checksum: ee359714c538ee8f328e147732aba629269849e159a88f96662c2e2d8ca175a6015e5364304bb8bada0249de0c335bafa64835abe09b5db895b702bce9159912
react: ^16.8.0 || ^17 || ^18
checksum: 7895d65b8458c42d46eb338803bb0fd1aab42fc69ecf80b47846eace9493a10cac5b05c9b744a5f9f1f7969a3e2703fc2118cdab97e49a7798a72d09f106383f
languageName: node
linkType: hard