Change client description component to use utils (#19165)
This commit is contained in:
parent
632d315c94
commit
d29f3e4dfc
11 changed files with 247 additions and 128 deletions
|
@ -437,7 +437,7 @@ describe("Clients test", () => {
|
||||||
it("Should fail to create imported client with empty ID", () => {
|
it("Should fail to create imported client with empty ID", () => {
|
||||||
commonPage.sidebar().goToClients();
|
commonPage.sidebar().goToClients();
|
||||||
cy.findByTestId("importClient").click();
|
cy.findByTestId("importClient").click();
|
||||||
cy.findByTestId("kc-client-id").click();
|
cy.findByTestId("clientId").click();
|
||||||
cy.findByText("Save").click();
|
cy.findByText("Save").click();
|
||||||
cy.findByText("Required field");
|
cy.findByText("Required field");
|
||||||
});
|
});
|
||||||
|
@ -463,7 +463,7 @@ describe("Clients test", () => {
|
||||||
|
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
//cy.findByTestId("realm-file").contains('"clientId": "identical"')
|
//cy.findByTestId("realm-file").contains('"clientId": "identical"')
|
||||||
cy.findByTestId("kc-client-id").click();
|
cy.findByTestId("clientId").click();
|
||||||
cy.findByText("Save").click();
|
cy.findByText("Save").click();
|
||||||
commonPage
|
commonPage
|
||||||
.masthead()
|
.masthead()
|
||||||
|
|
|
@ -3,9 +3,9 @@ import CommonPage from "../../../CommonPage";
|
||||||
export default class CreateClientPage extends CommonPage {
|
export default class CreateClientPage extends CommonPage {
|
||||||
private clientTypeDrpDwn = ".pf-c-select__toggle";
|
private clientTypeDrpDwn = ".pf-c-select__toggle";
|
||||||
private clientTypeList = ".pf-c-select__toggle + ul";
|
private clientTypeList = ".pf-c-select__toggle + ul";
|
||||||
private clientIdInput = "#kc-client-id";
|
private clientIdInput = "#clientId";
|
||||||
private clientIdError = "#kc-client-id + div";
|
private clientIdError = "#clientId + div";
|
||||||
private clientNameInput = "#kc-name";
|
private clientNameInput = "#name";
|
||||||
private clientDescriptionInput = "#kc-description";
|
private clientDescriptionInput = "#kc-description";
|
||||||
private alwaysDisplayInUISwitch =
|
private alwaysDisplayInUISwitch =
|
||||||
'[for="kc-always-display-in-ui-switch"] .pf-c-switch__toggle';
|
'[for="kc-always-display-in-ui-switch"] .pf-c-switch__toggle';
|
||||||
|
|
|
@ -1,12 +1,8 @@
|
||||||
import { FormGroup, Switch, ValidatedOptions } from "@patternfly/react-core";
|
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { HelpItem } from "ui-shared";
|
import { TextControl, TextAreaControl } from "ui-shared";
|
||||||
|
|
||||||
import { FormAccess } from "../components/form-access/FormAccess";
|
import { FormAccess } from "../components/form-access/FormAccess";
|
||||||
import { KeycloakTextArea } from "../components/keycloak-text-area/KeycloakTextArea";
|
import { DefaultSwitchControl } from "../components/SwitchControl";
|
||||||
import { KeycloakTextInput } from "../components/keycloak-text-input/KeycloakTextInput";
|
|
||||||
import { FormFields } from "./ClientDetails";
|
|
||||||
|
|
||||||
type ClientDescriptionProps = {
|
type ClientDescriptionProps = {
|
||||||
protocol?: string;
|
protocol?: string;
|
||||||
|
@ -17,105 +13,35 @@ export const ClientDescription = ({
|
||||||
hasConfigureAccess: configure,
|
hasConfigureAccess: configure,
|
||||||
}: ClientDescriptionProps) => {
|
}: ClientDescriptionProps) => {
|
||||||
const { t } = useTranslation("clients");
|
const { t } = useTranslation("clients");
|
||||||
const {
|
|
||||||
register,
|
|
||||||
control,
|
|
||||||
formState: { errors },
|
|
||||||
} = useFormContext<FormFields>();
|
|
||||||
return (
|
return (
|
||||||
<FormAccess role="manage-clients" fineGrainedAccess={configure} unWrap>
|
<FormAccess role="manage-clients" fineGrainedAccess={configure} unWrap>
|
||||||
<FormGroup
|
<TextControl
|
||||||
labelIcon={
|
name="clientId"
|
||||||
<HelpItem
|
|
||||||
helpText={t("clients-help:clientId")}
|
|
||||||
fieldLabelId="clientId"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={t("common:clientId")}
|
label={t("common:clientId")}
|
||||||
fieldId="kc-client-id"
|
labelIcon={t("clients-help:clientId")}
|
||||||
helperTextInvalid={t("common:required")}
|
rules={{ required: { value: true, message: t("common:required") } }}
|
||||||
validated={
|
/>
|
||||||
errors.clientId ? ValidatedOptions.error : ValidatedOptions.default
|
<TextControl
|
||||||
}
|
name="name"
|
||||||
isRequired
|
|
||||||
>
|
|
||||||
<KeycloakTextInput
|
|
||||||
{...register("clientId", { required: true })}
|
|
||||||
id="kc-client-id"
|
|
||||||
data-testid="kc-client-id"
|
|
||||||
validated={
|
|
||||||
errors.clientId ? ValidatedOptions.error : ValidatedOptions.default
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
<FormGroup
|
|
||||||
labelIcon={
|
|
||||||
<HelpItem
|
|
||||||
helpText={t("clients-help:clientName")}
|
|
||||||
fieldLabelId="name"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={t("common:name")}
|
label={t("common:name")}
|
||||||
fieldId="kc-name"
|
labelIcon={t("clients-help:clientName")}
|
||||||
>
|
/>
|
||||||
<KeycloakTextInput {...register("name")} id="kc-name" />
|
<TextAreaControl
|
||||||
</FormGroup>
|
name="description"
|
||||||
<FormGroup
|
|
||||||
labelIcon={
|
|
||||||
<HelpItem
|
|
||||||
helpText={t("clients-help:description")}
|
|
||||||
fieldLabelId="description"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={t("common:description")}
|
label={t("common:description")}
|
||||||
fieldId="kc-description"
|
labelIcon={t("clients-help:description")}
|
||||||
validated={
|
rules={{
|
||||||
errors.description ? ValidatedOptions.error : ValidatedOptions.default
|
maxLength: {
|
||||||
}
|
value: 255,
|
||||||
helperTextInvalid={errors.description?.message}
|
message: t("common:maxLength", { length: 255 }),
|
||||||
>
|
},
|
||||||
<KeycloakTextArea
|
}}
|
||||||
{...register("description", {
|
/>
|
||||||
maxLength: {
|
<DefaultSwitchControl
|
||||||
value: 255,
|
name="alwaysDisplayInConsole"
|
||||||
message: t("common:maxLength", { length: 255 }),
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
id="kc-description"
|
|
||||||
validated={
|
|
||||||
errors.description
|
|
||||||
? ValidatedOptions.error
|
|
||||||
: ValidatedOptions.default
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
<FormGroup
|
|
||||||
label={t("alwaysDisplayInUI")}
|
label={t("alwaysDisplayInUI")}
|
||||||
labelIcon={
|
labelIcon={t("clients-help:alwaysDisplayInUI")}
|
||||||
<HelpItem
|
/>
|
||||||
helpText={t("clients-help:alwaysDisplayInUI")}
|
|
||||||
fieldLabelId="clients:alwaysDisplayInUI"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
fieldId="kc-always-display-in-ui"
|
|
||||||
hasNoPaddingTop
|
|
||||||
>
|
|
||||||
<Controller
|
|
||||||
name="alwaysDisplayInConsole"
|
|
||||||
defaultValue={false}
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Switch
|
|
||||||
id="kc-always-display-in-ui-switch"
|
|
||||||
label={t("common:on")}
|
|
||||||
labelOff={t("common:off")}
|
|
||||||
isChecked={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
aria-label={t("alwaysDisplayInUI")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
</FormAccess>
|
</FormAccess>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
25
js/apps/admin-ui/src/components/SwitchControl.tsx
Normal file
25
js/apps/admin-ui/src/components/SwitchControl.tsx
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { SwitchProps } from "@patternfly/react-core";
|
||||||
|
import { FieldPath, FieldValues, UseControllerProps } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { SwitchControl } from "ui-shared";
|
||||||
|
|
||||||
|
type AdminSwitchControlProps<
|
||||||
|
T extends FieldValues,
|
||||||
|
P extends FieldPath<T> = FieldPath<T>
|
||||||
|
> = SwitchProps &
|
||||||
|
UseControllerProps<T, P> & {
|
||||||
|
name: string;
|
||||||
|
label?: string;
|
||||||
|
labelIcon?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DefaultSwitchControl = <
|
||||||
|
T extends FieldValues,
|
||||||
|
P extends FieldPath<T> = FieldPath<T>
|
||||||
|
>(
|
||||||
|
props: AdminSwitchControlProps<T, P>
|
||||||
|
) => {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
|
||||||
|
return <SwitchControl {...props} labelOn={t("on")} labelOff={t("off")} />;
|
||||||
|
};
|
36
js/libs/ui-shared/src/controls/FormLabel.tsx
Normal file
36
js/libs/ui-shared/src/controls/FormLabel.tsx
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import { FormGroup, ValidatedOptions } from "@patternfly/react-core";
|
||||||
|
import { PropsWithChildren } from "react";
|
||||||
|
import { FieldError, FieldValues, Merge } from "react-hook-form";
|
||||||
|
import { HelpItem } from "./HelpItem";
|
||||||
|
|
||||||
|
export type FormLabelProps<T extends FieldValues = FieldValues> = {
|
||||||
|
label?: string;
|
||||||
|
name: string;
|
||||||
|
labelIcon?: string;
|
||||||
|
error?: FieldError | Merge<FieldError, T>;
|
||||||
|
isRequired: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FormLabel = ({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
labelIcon,
|
||||||
|
error,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: PropsWithChildren<FormLabelProps>) => (
|
||||||
|
<FormGroup
|
||||||
|
label={label || name}
|
||||||
|
fieldId={name}
|
||||||
|
labelIcon={
|
||||||
|
labelIcon ? (
|
||||||
|
<HelpItem helpText={labelIcon} fieldLabelId={name} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
helperTextInvalid={error?.message}
|
||||||
|
validated={error ? ValidatedOptions.error : ValidatedOptions.default}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</FormGroup>
|
||||||
|
);
|
|
@ -8,12 +8,12 @@ import {
|
||||||
UseControllerProps,
|
UseControllerProps,
|
||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
import {
|
import {
|
||||||
FormGroup,
|
|
||||||
Select,
|
Select,
|
||||||
SelectOption,
|
SelectOption,
|
||||||
SelectProps,
|
SelectProps,
|
||||||
ValidatedOptions,
|
ValidatedOptions,
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
|
import { FormLabel } from "./FormLabel";
|
||||||
|
|
||||||
type Option = {
|
type Option = {
|
||||||
key: string;
|
key: string;
|
||||||
|
@ -50,14 +50,11 @@ export const SelectControl = <
|
||||||
} = useFormContext();
|
} = useFormContext();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<FormGroup
|
<FormLabel
|
||||||
|
name={name}
|
||||||
|
label={label}
|
||||||
isRequired={controller.rules?.required === true}
|
isRequired={controller.rules?.required === true}
|
||||||
label={label || name}
|
error={errors[name]}
|
||||||
fieldId={name}
|
|
||||||
helperTextInvalid={errors[name]?.message as string}
|
|
||||||
validated={
|
|
||||||
errors[name] ? ValidatedOptions.error : ValidatedOptions.default
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Controller
|
<Controller
|
||||||
{...controller}
|
{...controller}
|
||||||
|
@ -97,6 +94,6 @@ export const SelectControl = <
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormLabel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
56
js/libs/ui-shared/src/controls/SwitchControl.tsx
Normal file
56
js/libs/ui-shared/src/controls/SwitchControl.tsx
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
FieldValues,
|
||||||
|
FieldPath,
|
||||||
|
UseControllerProps,
|
||||||
|
PathValue,
|
||||||
|
useFormContext,
|
||||||
|
} from "react-hook-form";
|
||||||
|
import { SwitchProps, Switch } from "@patternfly/react-core";
|
||||||
|
import { FormLabel } from "./FormLabel";
|
||||||
|
|
||||||
|
export type SwitchControlProps<
|
||||||
|
T extends FieldValues,
|
||||||
|
P extends FieldPath<T> = FieldPath<T>
|
||||||
|
> = SwitchProps &
|
||||||
|
UseControllerProps<T, P> & {
|
||||||
|
name: string;
|
||||||
|
label?: string;
|
||||||
|
labelIcon?: string;
|
||||||
|
labelOn: string;
|
||||||
|
labelOff: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SwitchControl = <
|
||||||
|
T extends FieldValues,
|
||||||
|
P extends FieldPath<T> = FieldPath<T>
|
||||||
|
>(
|
||||||
|
props: SwitchControlProps<T, P>
|
||||||
|
) => {
|
||||||
|
const defaultValue = props.defaultValue ?? (false as PathValue<T, P>);
|
||||||
|
const { control } = useFormContext();
|
||||||
|
return (
|
||||||
|
<FormLabel
|
||||||
|
name={props.name}
|
||||||
|
isRequired={props.rules?.required === true}
|
||||||
|
label={props.label}
|
||||||
|
labelIcon={props.labelIcon}
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={props.name}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Switch
|
||||||
|
id={props.name}
|
||||||
|
data-testid={props.name}
|
||||||
|
label={props.labelOn}
|
||||||
|
labelOff={props.labelOff}
|
||||||
|
isChecked={value}
|
||||||
|
onChange={(checked) => onChange(checked)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormLabel>
|
||||||
|
);
|
||||||
|
};
|
56
js/libs/ui-shared/src/controls/TextAreaControl.tsx
Normal file
56
js/libs/ui-shared/src/controls/TextAreaControl.tsx
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import { ValidatedOptions } from "@patternfly/react-core";
|
||||||
|
import {
|
||||||
|
FieldPath,
|
||||||
|
FieldValues,
|
||||||
|
PathValue,
|
||||||
|
useController,
|
||||||
|
UseControllerProps,
|
||||||
|
} from "react-hook-form";
|
||||||
|
import { FormLabel } from "./FormLabel";
|
||||||
|
|
||||||
|
import { KeycloakTextArea } from "./keycloak-text-area/KeycloakTextArea";
|
||||||
|
|
||||||
|
export type TextAreaControlProps<
|
||||||
|
T extends FieldValues,
|
||||||
|
P extends FieldPath<T> = FieldPath<T>
|
||||||
|
> = UseControllerProps<T, P> & {
|
||||||
|
label: string;
|
||||||
|
labelIcon?: string;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TextAreaControl = <
|
||||||
|
T extends FieldValues,
|
||||||
|
P extends FieldPath<T> = FieldPath<T>
|
||||||
|
>(
|
||||||
|
props: TextAreaControlProps<T, P>
|
||||||
|
) => {
|
||||||
|
const required = !!props.rules?.required;
|
||||||
|
const defaultValue = props.defaultValue ?? ("" as PathValue<T, P>);
|
||||||
|
|
||||||
|
const { field, fieldState } = useController({
|
||||||
|
...props,
|
||||||
|
defaultValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormLabel
|
||||||
|
isRequired={required}
|
||||||
|
label={props.label}
|
||||||
|
labelIcon={props.labelIcon}
|
||||||
|
name={props.name}
|
||||||
|
error={fieldState.error}
|
||||||
|
>
|
||||||
|
<KeycloakTextArea
|
||||||
|
isRequired={required}
|
||||||
|
id={props.name}
|
||||||
|
data-testid={props.name}
|
||||||
|
validated={
|
||||||
|
fieldState.error ? ValidatedOptions.error : ValidatedOptions.default
|
||||||
|
}
|
||||||
|
isDisabled={props.isDisabled}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormLabel>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,4 +1,4 @@
|
||||||
import { FormGroup, ValidatedOptions } from "@patternfly/react-core";
|
import { ValidatedOptions } from "@patternfly/react-core";
|
||||||
import {
|
import {
|
||||||
FieldPath,
|
FieldPath,
|
||||||
FieldValues,
|
FieldValues,
|
||||||
|
@ -8,7 +8,7 @@ import {
|
||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
|
|
||||||
import { KeycloakTextInput } from "../keycloak-text-input/KeycloakTextInput";
|
import { KeycloakTextInput } from "../keycloak-text-input/KeycloakTextInput";
|
||||||
import { HelpItem } from "./HelpItem";
|
import { FormLabel } from "./FormLabel";
|
||||||
|
|
||||||
export type TextControlProps<
|
export type TextControlProps<
|
||||||
T extends FieldValues,
|
T extends FieldValues,
|
||||||
|
@ -34,29 +34,23 @@ export const TextControl = <
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormGroup
|
<FormLabel
|
||||||
isRequired={required}
|
name={props.name}
|
||||||
label={props.label}
|
label={props.label}
|
||||||
labelIcon={
|
labelIcon={props.labelIcon}
|
||||||
props.labelIcon ? (
|
isRequired={required}
|
||||||
<HelpItem helpText={props.labelIcon} fieldLabelId={props.name} />
|
error={fieldState.error}
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
fieldId={props.name}
|
|
||||||
helperTextInvalid={fieldState.error?.message}
|
|
||||||
validated={
|
|
||||||
fieldState.error ? ValidatedOptions.error : ValidatedOptions.default
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<KeycloakTextInput
|
<KeycloakTextInput
|
||||||
isRequired={required}
|
isRequired={required}
|
||||||
id={props.name}
|
id={props.name}
|
||||||
|
data-testid={props.name}
|
||||||
validated={
|
validated={
|
||||||
fieldState.error ? ValidatedOptions.error : ValidatedOptions.default
|
fieldState.error ? ValidatedOptions.error : ValidatedOptions.default
|
||||||
}
|
}
|
||||||
isDisabled={props.isDisabled}
|
isDisabled={props.isDisabled}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormLabel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { TextArea, TextAreaProps } from "@patternfly/react-core";
|
||||||
|
import { ComponentProps, forwardRef, HTMLProps } from "react";
|
||||||
|
|
||||||
|
// PatternFly changes the signature of the 'onChange' handler for textarea elements.
|
||||||
|
// This causes issues with React Hook Form as it expects the default signature for a textarea element.
|
||||||
|
// So we have to create this wrapper component that takes care of converting these signatures for us.
|
||||||
|
|
||||||
|
export type KeycloakTextAreaProps = Omit<
|
||||||
|
ComponentProps<typeof TextArea>,
|
||||||
|
"onChange"
|
||||||
|
> &
|
||||||
|
Pick<HTMLProps<HTMLTextAreaElement>, "onChange">;
|
||||||
|
|
||||||
|
export const KeycloakTextArea = forwardRef<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
KeycloakTextAreaProps
|
||||||
|
>(({ onChange, ...props }, ref) => {
|
||||||
|
const onChangeForward: TextAreaProps["onChange"] = (_, event) =>
|
||||||
|
onChange?.(event);
|
||||||
|
|
||||||
|
return <TextArea {...props} ref={ref} onChange={onChangeForward} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
// We need to fake the displayName to match what PatternFly expects.
|
||||||
|
// This is because PatternFly uses it to filter children in certain aspects.
|
||||||
|
// This is a stupid approach, but it's not like we can change that.
|
||||||
|
KeycloakTextArea.displayName = "TextArea";
|
|
@ -1,6 +1,8 @@
|
||||||
export { ContinueCancelModal } from "./continue-cancel/ContinueCancelModal";
|
export { ContinueCancelModal } from "./continue-cancel/ContinueCancelModal";
|
||||||
export { SelectControl } from "./controls/SelectControl";
|
export { SelectControl } from "./controls/SelectControl";
|
||||||
|
export { SwitchControl } from "./controls/SwitchControl";
|
||||||
export { TextControl } from "./controls/TextControl";
|
export { TextControl } from "./controls/TextControl";
|
||||||
|
export { TextAreaControl } from "./controls/TextAreaControl";
|
||||||
export { HelpItem } from "./controls/HelpItem";
|
export { HelpItem } from "./controls/HelpItem";
|
||||||
export { useHelp, Help } from "./context/HelpContext";
|
export { useHelp, Help } from "./context/HelpContext";
|
||||||
export { KeycloakTextInput } from "./keycloak-text-input/KeycloakTextInput";
|
export { KeycloakTextInput } from "./keycloak-text-input/KeycloakTextInput";
|
||||||
|
|
Loading…
Reference in a new issue