mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-13 08:55:44 +01:00
Introduce new combobox and make it work with chip input
We introduced a new accessible combobox component. This component is based on headless ui and made compatible with our components and forms. Also we replaced the outdated `Autocomplete` component with the new combobox. Co-authored-by: Konstantin Schaper <konstantin.schaper@cloudogu.com> Reviewed-by: Florian Scholdei <florian.scholdei@cloudogu.com>
This commit is contained in:
4
gradle/changelog/combobox.yaml
Normal file
4
gradle/changelog/combobox.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
- type: added
|
||||||
|
description: New accessible Combobox component
|
||||||
|
- type: changed
|
||||||
|
description: Replace outdated `Autocomplete` component with new combobox
|
||||||
@@ -35,7 +35,8 @@
|
|||||||
"query-string": "6.14.1",
|
"query-string": "6.14.1",
|
||||||
"react": "^17.0.1",
|
"react": "^17.0.1",
|
||||||
"react-query": "^3.25.1",
|
"react-query": "^3.25.1",
|
||||||
"react-router-dom": "^5.3.1"
|
"react-router-dom": "^5.3.1",
|
||||||
|
"react-i18next": "11"
|
||||||
},
|
},
|
||||||
"babel": {
|
"babel": {
|
||||||
"presets": [
|
"presets": [
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export * from "./usePluginCenterAuthInfo";
|
|||||||
export * from "./compare";
|
export * from "./compare";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
export * from "./links";
|
export * from "./links";
|
||||||
|
export { useNamespaceOptions, useGroupOptions, useUserOptions } from "./useAutocompleteOptions";
|
||||||
|
|
||||||
export { default as ApiProvider } from "./ApiProvider";
|
export { default as ApiProvider } from "./ApiProvider";
|
||||||
export * from "./ApiProvider";
|
export * from "./ApiProvider";
|
||||||
|
|||||||
96
scm-ui/ui-api/src/useAutocompleteOptions.ts
Normal file
96
scm-ui/ui-api/src/useAutocompleteOptions.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from "./apiclient";
|
||||||
|
import { useQuery } from "react-query";
|
||||||
|
import { useIndexLinks } from "./base";
|
||||||
|
import { AutocompleteObject, Link, Option } from "@scm-manager/ui-types";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
const defaultLabelFactory = (element: AutocompleteObject): string =>
|
||||||
|
element.displayName ? `${element.displayName} (${element.id})` : element.id;
|
||||||
|
|
||||||
|
function useAutocompleteOptions(
|
||||||
|
query = "",
|
||||||
|
link?: string,
|
||||||
|
options: {
|
||||||
|
labelFactory?: (element: AutocompleteObject) => string;
|
||||||
|
allowArbitraryValues?: (query: string) => AutocompleteObject;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const [t] = useTranslation("commons");
|
||||||
|
return useQuery<Option<AutocompleteObject>[], Error>(
|
||||||
|
["options", link, query],
|
||||||
|
() =>
|
||||||
|
apiClient
|
||||||
|
.get(link + "?q=" + query)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((json: Array<AutocompleteObject>) => {
|
||||||
|
const result: Array<Option<AutocompleteObject>> = json.map((element) => ({
|
||||||
|
value: element,
|
||||||
|
label: options.labelFactory ? options.labelFactory(element) : defaultLabelFactory(element),
|
||||||
|
}));
|
||||||
|
if (
|
||||||
|
options.allowArbitraryValues &&
|
||||||
|
!result.some(
|
||||||
|
(opt) => opt.value.id === query || opt.value.displayName?.toLowerCase() === query.toLowerCase()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
result.unshift({
|
||||||
|
value: options.allowArbitraryValues(query),
|
||||||
|
label: query,
|
||||||
|
displayValue: t("form.combobox.arbitraryDisplayValue", { query }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
enabled: query.length > 1 && !!link,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGroupOptions = (query?: string) => {
|
||||||
|
const indexLinks = useIndexLinks();
|
||||||
|
const autocompleteLink = (indexLinks.autocomplete as Link[]).find((i) => i.name === "groups");
|
||||||
|
return useAutocompleteOptions(query, autocompleteLink?.href, {
|
||||||
|
allowArbitraryValues: (query) => ({ id: query, displayName: query }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useNamespaceOptions = (query?: string) => {
|
||||||
|
const indexLinks = useIndexLinks();
|
||||||
|
const autocompleteLink = (indexLinks.autocomplete as Link[]).find((i) => i.name === "namespaces");
|
||||||
|
return useAutocompleteOptions(query, autocompleteLink?.href, {
|
||||||
|
allowArbitraryValues: (query) => ({ id: query, displayName: query }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUserOptions = (query?: string) => {
|
||||||
|
const indexLinks = useIndexLinks();
|
||||||
|
const autocompleteLink = (indexLinks.autocomplete as Link[]).find((i) => i.name === "users");
|
||||||
|
return useAutocompleteOptions(query, autocompleteLink?.href, {
|
||||||
|
allowArbitraryValues: (query) => ({ id: query, displayName: query }),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -46,11 +46,17 @@ type Props = {
|
|||||||
|
|
||||||
type State = {};
|
type State = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
* @since 2.45.0
|
||||||
|
*
|
||||||
|
* Use {@link Combobox} instead
|
||||||
|
*/
|
||||||
class Autocomplete extends React.Component<Props, State> {
|
class Autocomplete extends React.Component<Props, State> {
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
placeholder: "Type here",
|
placeholder: "Type here",
|
||||||
loadingMessage: "Loading...",
|
loadingMessage: "Loading...",
|
||||||
noOptionsMessage: "No suggestion available"
|
noOptionsMessage: "No suggestion available",
|
||||||
};
|
};
|
||||||
|
|
||||||
handleInputChange = (newValue: ValueType<SelectValue>, action: ActionMeta) => {
|
handleInputChange = (newValue: ValueType<SelectValue>, action: ActionMeta) => {
|
||||||
@@ -67,7 +73,7 @@ class Autocomplete extends React.Component<Props, State> {
|
|||||||
selectValue: ValueType<SelectValue>,
|
selectValue: ValueType<SelectValue>,
|
||||||
selectOptions: readonly SelectValue[]
|
selectOptions: readonly SelectValue[]
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const isNotDuplicated = !selectOptions.map(option => option.label).includes(inputValue);
|
const isNotDuplicated = !selectOptions.map((option) => option.label).includes(inputValue);
|
||||||
const isNotEmpty = inputValue !== "";
|
const isNotEmpty = inputValue !== "";
|
||||||
return isNotEmpty && isNotDuplicated;
|
return isNotEmpty && isNotDuplicated;
|
||||||
};
|
};
|
||||||
@@ -85,7 +91,7 @@ class Autocomplete extends React.Component<Props, State> {
|
|||||||
informationMessage,
|
informationMessage,
|
||||||
creatable,
|
creatable,
|
||||||
className,
|
className,
|
||||||
disabled
|
disabled,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const asyncProps = {
|
const asyncProps = {
|
||||||
@@ -99,7 +105,7 @@ class Autocomplete extends React.Component<Props, State> {
|
|||||||
loadingMessage: () => loadingMessage,
|
loadingMessage: () => loadingMessage,
|
||||||
noOptionsMessage: () => noOptionsMessage,
|
noOptionsMessage: () => noOptionsMessage,
|
||||||
isDisabled: disabled,
|
isDisabled: disabled,
|
||||||
"aria-label": helpText || label
|
"aria-label": helpText || label,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -110,13 +116,13 @@ class Autocomplete extends React.Component<Props, State> {
|
|||||||
<AsyncCreatable
|
<AsyncCreatable
|
||||||
{...asyncProps}
|
{...asyncProps}
|
||||||
isValidNewOption={this.isValidNewOption}
|
isValidNewOption={this.isValidNewOption}
|
||||||
onCreateOption={newValue => {
|
onCreateOption={(newValue) => {
|
||||||
this.selectValue({
|
this.selectValue({
|
||||||
label: newValue,
|
label: newValue,
|
||||||
value: {
|
value: {
|
||||||
id: newValue,
|
id: newValue,
|
||||||
displayName: newValue
|
displayName: newValue,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ import React, { FC } from "react";
|
|||||||
import UserGroupAutocomplete, { AutocompleteProps } from "./UserGroupAutocomplete";
|
import UserGroupAutocomplete, { AutocompleteProps } from "./UserGroupAutocomplete";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
* @since 2.45.0
|
||||||
|
*
|
||||||
|
* Use {@link Combobox} instead
|
||||||
|
*/
|
||||||
const GroupAutocomplete: FC<AutocompleteProps> = (props) => {
|
const GroupAutocomplete: FC<AutocompleteProps> = (props) => {
|
||||||
const [t] = useTranslation("commons");
|
const [t] = useTranslation("commons");
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ import React, { FC } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import UserGroupAutocomplete, { AutocompleteProps } from "./UserGroupAutocomplete";
|
import UserGroupAutocomplete, { AutocompleteProps } from "./UserGroupAutocomplete";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
* @since 2.45.0
|
||||||
|
*
|
||||||
|
* Use {@link Combobox} instead
|
||||||
|
*/
|
||||||
const UserAutocomplete: FC<AutocompleteProps> = (props) => {
|
const UserAutocomplete: FC<AutocompleteProps> = (props) => {
|
||||||
const [t] = useTranslation("commons");
|
const [t] = useTranslation("commons");
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ type Props = AutocompleteProps & {
|
|||||||
placeholder: string;
|
placeholder: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
* @since 2.45.0
|
||||||
|
*
|
||||||
|
* Use {@link Combobox} instead
|
||||||
|
*/
|
||||||
const UserGroupAutocomplete: FC<Props> = ({ autocompleteLink, valueSelected, ...props }) => {
|
const UserGroupAutocomplete: FC<Props> = ({ autocompleteLink, valueSelected, ...props }) => {
|
||||||
const loadSuggestions = useSuggestions(autocompleteLink);
|
const loadSuggestions = useSuggestions(autocompleteLink);
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ type Options = {
|
|||||||
timeZone?: string;
|
timeZone?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const chooseLocale = (language: string, languages?: string[]) => {
|
export const chooseLocale = (language: string, languages?: readonly string[]) => {
|
||||||
for (const lng of languages || []) {
|
for (const lng of languages || []) {
|
||||||
const locale = supportedLocales[lng];
|
const locale = supportedLocales[lng];
|
||||||
if (locale) {
|
if (locale) {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"@scm-manager/ui-buttons": "2.44.2-SNAPSHOT",
|
"@scm-manager/ui-buttons": "2.44.2-SNAPSHOT",
|
||||||
"@scm-manager/ui-overlays": "2.44.2-SNAPSHOT",
|
"@scm-manager/ui-overlays": "2.44.2-SNAPSHOT",
|
||||||
"@scm-manager/ui-api": "2.44.2-SNAPSHOT",
|
"@scm-manager/ui-api": "2.44.2-SNAPSHOT",
|
||||||
|
"@headlessui/react": "^1.7.15",
|
||||||
"@radix-ui/react-slot": "^1.0.1",
|
"@radix-ui/react-slot": "^1.0.1",
|
||||||
"@radix-ui/react-visually-hidden": "^1.0.3"
|
"@radix-ui/react-visually-hidden": "^1.0.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ import AddListEntryForm from "./AddListEntryForm";
|
|||||||
import { ScmFormListContextProvider } from "./ScmFormListContext";
|
import { ScmFormListContextProvider } from "./ScmFormListContext";
|
||||||
import { HalRepresentation } from "@scm-manager/ui-types";
|
import { HalRepresentation } from "@scm-manager/ui-types";
|
||||||
import ControlledChipInputField from "./chip-input/ControlledChipInputField";
|
import ControlledChipInputField from "./chip-input/ControlledChipInputField";
|
||||||
|
import Combobox from "./combobox/Combobox";
|
||||||
|
import ControlledComboboxField from "./combobox/ControlledComboboxField";
|
||||||
|
import { defaultOptionFactory } from "./helpers";
|
||||||
|
|
||||||
export type SimpleWebHookConfiguration = {
|
export type SimpleWebHookConfiguration = {
|
||||||
urlPattern: string;
|
urlPattern: string;
|
||||||
@@ -241,6 +244,8 @@ storiesOf("Forms", module)
|
|||||||
disableB: false,
|
disableB: false,
|
||||||
disableC: true,
|
disableC: true,
|
||||||
labels: ["test"],
|
labels: ["test"],
|
||||||
|
people: [{ displayName: "Trillian", id: 2 }],
|
||||||
|
author: null,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ControlledInputField name="url" />
|
<ControlledInputField name="url" />
|
||||||
@@ -250,6 +255,23 @@ storiesOf("Forms", module)
|
|||||||
<ControlledCheckboxField name="disableB" />
|
<ControlledCheckboxField name="disableB" />
|
||||||
<ControlledCheckboxField name="disableC" />
|
<ControlledCheckboxField name="disableC" />
|
||||||
<ControlledChipInputField name="labels" />
|
<ControlledChipInputField name="labels" />
|
||||||
|
<ControlledChipInputField
|
||||||
|
name="people"
|
||||||
|
optionFactory={(person) => ({ label: person.displayName, value: person })}
|
||||||
|
>
|
||||||
|
<Combobox
|
||||||
|
options={[
|
||||||
|
{ label: "Zenod", value: { id: 1, displayName: "Zenod" } },
|
||||||
|
{ label: "Arthur", value: { id: 3, displayName: "Arthur" } },
|
||||||
|
{ label: "Cookie Monster", value: { id: 4, displayName: "Cookie Monster" } },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</ControlledChipInputField>
|
||||||
|
<ControlledComboboxField
|
||||||
|
options={["Zenod", "Arthur", "Trillian"].map(defaultOptionFactory)}
|
||||||
|
name="author"
|
||||||
|
nullable
|
||||||
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
))
|
))
|
||||||
.add("ReadOnly", () => (
|
.add("ReadOnly", () => (
|
||||||
|
|||||||
@@ -25,16 +25,45 @@
|
|||||||
import { storiesOf } from "@storybook/react";
|
import { storiesOf } from "@storybook/react";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import ChipInputField from "./ChipInputField";
|
import ChipInputField from "./ChipInputField";
|
||||||
|
import Combobox from "../combobox/Combobox";
|
||||||
|
import { Option } from "@scm-manager/ui-types";
|
||||||
|
|
||||||
storiesOf("Chip Input Field", module).add("Default", () => {
|
storiesOf("Chip Input Field", module)
|
||||||
const [value, setValue] = useState(["test"]);
|
.add("Default", () => {
|
||||||
|
const [value, setValue] = useState<Option<string>[]>([]);
|
||||||
return (
|
return (
|
||||||
<ChipInputField
|
<ChipInputField
|
||||||
value={value}
|
value={value}
|
||||||
onChange={setValue}
|
onChange={setValue}
|
||||||
label="Test Chips"
|
label="Test Chips"
|
||||||
placeholder="This is a placeholder..."
|
placeholder="Type a new chip name and press enter to add"
|
||||||
aria-label="My personal chip list"
|
aria-label="My personal chip list"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
})
|
||||||
|
.add("With Autocomplete", () => {
|
||||||
|
const people = ["Durward Reynolds", "Kenton Towne", "Therese Wunsch", "Benedict Kessler", "Katelyn Rohan"];
|
||||||
|
|
||||||
|
const [value, setValue] = useState<Option<string>[]>([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChipInputField
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
label="Persons"
|
||||||
|
placeholder="Enter a new person"
|
||||||
|
aria-label="Enter a new person"
|
||||||
|
>
|
||||||
|
<Combobox
|
||||||
|
options={(query: string) =>
|
||||||
|
Promise.resolve(
|
||||||
|
people
|
||||||
|
.map<Option<string>>((p) => ({ label: p, value: p }))
|
||||||
|
.filter((t) => !value.some((val) => val.label === t.label) && t.label.startsWith(query))
|
||||||
|
.concat({ label: query, value: query, displayValue: `Use '${query}'` })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ChipInputField>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { ComponentProps, useCallback } from "react";
|
import React, { KeyboardEventHandler, ReactElement, useCallback } from "react";
|
||||||
import { createAttributesForTesting, useGeneratedId } from "@scm-manager/ui-components";
|
import { createAttributesForTesting, useGeneratedId } from "@scm-manager/ui-components";
|
||||||
import Field from "../base/Field";
|
import Field from "../base/Field";
|
||||||
import Label from "../base/label/Label";
|
import Label from "../base/label/Label";
|
||||||
@@ -34,8 +34,10 @@ import { createVariantClass } from "../variants";
|
|||||||
import ChipInput, { NewChipInput } from "../headless-chip-input/ChipInput";
|
import ChipInput, { NewChipInput } from "../headless-chip-input/ChipInput";
|
||||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { withForwardRef } from "../helpers";
|
||||||
|
import { Option } from "@scm-manager/ui-types";
|
||||||
|
|
||||||
const StyledChipInput = styled(ChipInput)`
|
const StyledChipInput: typeof ChipInput = styled(ChipInput)`
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
height: min-content;
|
height: min-content;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -48,28 +50,44 @@ const StyledChipInput = styled(ChipInput)`
|
|||||||
const StyledInput = styled(NewChipInput)`
|
const StyledInput = styled(NewChipInput)`
|
||||||
color: var(--scm-secondary-more-color);
|
color: var(--scm-secondary-more-color);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
height: initial;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
` as unknown as typeof NewChipInput;
|
||||||
|
|
||||||
|
const StyledDelete = styled(ChipInput.Chip.Delete)`
|
||||||
|
&:focus {
|
||||||
|
outline-offset: 0;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type InputFieldProps = {
|
type InputFieldProps<T> = {
|
||||||
label: string;
|
label: string;
|
||||||
createDeleteText?: (value: string) => string;
|
createDeleteText?: (value: string) => string;
|
||||||
helpText?: string;
|
helpText?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
} & Pick<ComponentProps<typeof NewChipInput>, "placeholder"> &
|
children?: ReactElement;
|
||||||
Pick<ComponentProps<typeof ChipInput>, "onChange" | "value" | "readOnly" | "disabled"> &
|
placeholder?: string;
|
||||||
Pick<ComponentProps<typeof Field>, "className">;
|
onChange?: (newValue: Option<T>[]) => void;
|
||||||
|
value?: Option<T>[] | null;
|
||||||
|
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
|
||||||
|
readOnly?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isNewItemDuplicate?: (existingItem: Option<T>, newItem: Option<T>) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @beta
|
* @beta
|
||||||
* @since 2.44.0
|
* @since 2.44.0
|
||||||
*/
|
*/
|
||||||
const ChipInputField = React.forwardRef<HTMLInputElement, InputFieldProps>(
|
const ChipInputField = function ChipInputField<T>(
|
||||||
(
|
|
||||||
{
|
{
|
||||||
label,
|
label,
|
||||||
helpText,
|
helpText,
|
||||||
@@ -83,10 +101,13 @@ const ChipInputField = React.forwardRef<HTMLInputElement, InputFieldProps>(
|
|||||||
className,
|
className,
|
||||||
testId,
|
testId,
|
||||||
id,
|
id,
|
||||||
|
children,
|
||||||
|
isLoading,
|
||||||
|
isNewItemDuplicate,
|
||||||
...props
|
...props
|
||||||
},
|
}: InputFieldProps<T>,
|
||||||
ref
|
ref: React.ForwardedRef<HTMLInputElement>
|
||||||
) => {
|
) {
|
||||||
const [t] = useTranslation("commons", { keyPrefix: "form.chipList" });
|
const [t] = useTranslation("commons", { keyPrefix: "form.chipList" });
|
||||||
const deleteTextCallback = useCallback(
|
const deleteTextCallback = useCallback(
|
||||||
(item) => (createDeleteText ? createDeleteText(item) : t("delete", { item })),
|
(item) => (createDeleteText ? createDeleteText(item) : t("delete", { item })),
|
||||||
@@ -102,25 +123,29 @@ const ChipInputField = React.forwardRef<HTMLInputElement, InputFieldProps>(
|
|||||||
{label}
|
{label}
|
||||||
{helpText ? <Help className="ml-1" text={helpText} /> : null}
|
{helpText ? <Help className="ml-1" text={helpText} /> : null}
|
||||||
</Label>
|
</Label>
|
||||||
|
<div className={classNames("control", { "is-loading": isLoading })}>
|
||||||
<StyledChipInput
|
<StyledChipInput
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={(e) => onChange && onChange(e ?? [])}
|
||||||
className="is-flex is-flex-wrap-wrap input"
|
className="is-flex is-flex-wrap-wrap input"
|
||||||
aria-labelledby={labelId}
|
aria-labelledby={labelId}
|
||||||
disabled={readOnly || disabled}
|
disabled={readOnly || disabled}
|
||||||
|
isNewItemDuplicate={isNewItemDuplicate}
|
||||||
>
|
>
|
||||||
{value?.map((val, index) => (
|
{value?.map((option, index) => (
|
||||||
<ChipInput.Chip key={`${val}-${index}`} className="tag is-light">
|
<ChipInput.Chip key={option.label} className="tag is-light is-overflow-hidden">
|
||||||
{val}
|
<span className="is-ellipsis-overflow">{option.label}</span>
|
||||||
<ChipInput.Chip.Delete aria-label={deleteTextCallback(val)} index={index} className="delete is-small" />
|
<StyledDelete aria-label={deleteTextCallback(option.label)} index={index} className="delete is-small" />
|
||||||
</ChipInput.Chip>
|
</ChipInput.Chip>
|
||||||
))}
|
))}
|
||||||
<StyledInput
|
<StyledInput
|
||||||
{...props}
|
{...props}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"is-borderless",
|
"is-borderless",
|
||||||
"is-flex-grow-1",
|
|
||||||
"has-background-transparent",
|
"has-background-transparent",
|
||||||
|
"is-shadowless",
|
||||||
|
"input",
|
||||||
|
"is-ellipsis-overflow",
|
||||||
createVariantClass(variant)
|
createVariantClass(variant)
|
||||||
)}
|
)}
|
||||||
placeholder={!readOnly && !disabled ? placeholder : ""}
|
placeholder={!readOnly && !disabled ? placeholder : ""}
|
||||||
@@ -128,14 +153,16 @@ const ChipInputField = React.forwardRef<HTMLInputElement, InputFieldProps>(
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
aria-describedby={inputDescriptionId}
|
aria-describedby={inputDescriptionId}
|
||||||
{...createAttributesForTesting(testId)}
|
{...createAttributesForTesting(testId)}
|
||||||
/>
|
>
|
||||||
|
{children ? children : null}
|
||||||
|
</StyledInput>
|
||||||
</StyledChipInput>
|
</StyledChipInput>
|
||||||
|
</div>
|
||||||
<VisuallyHidden aria-hidden id={inputDescriptionId}>
|
<VisuallyHidden aria-hidden id={inputDescriptionId}>
|
||||||
{t("input.description")}
|
{t("input.description")}
|
||||||
</VisuallyHidden>
|
</VisuallyHidden>
|
||||||
{error ? <FieldMessage variant={variant}>{error}</FieldMessage> : null}
|
{error ? <FieldMessage variant={variant}>{error}</FieldMessage> : null}
|
||||||
</Field>
|
</Field>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
);
|
export default withForwardRef(ChipInputField);
|
||||||
export default ChipInputField;
|
|
||||||
|
|||||||
@@ -26,12 +26,13 @@ import React, { ComponentProps } from "react";
|
|||||||
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
|
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
|
||||||
import { useScmFormContext } from "../ScmFormContext";
|
import { useScmFormContext } from "../ScmFormContext";
|
||||||
import { useScmFormPathContext } from "../FormPathContext";
|
import { useScmFormPathContext } from "../FormPathContext";
|
||||||
import { prefixWithoutIndices } from "../helpers";
|
import { defaultOptionFactory, prefixWithoutIndices } from "../helpers";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import ChipInputField from "./ChipInputField";
|
import ChipInputField from "./ChipInputField";
|
||||||
|
import { Option } from "@scm-manager/ui-types";
|
||||||
|
|
||||||
type Props<T extends Record<string, unknown>> = Omit<
|
type Props<T extends Record<string, unknown>> = Omit<
|
||||||
ComponentProps<typeof ChipInputField>,
|
Parameters<typeof ChipInputField>[0],
|
||||||
"error" | "createDeleteText" | "label" | "defaultChecked" | "required" | keyof ControllerRenderProps
|
"error" | "createDeleteText" | "label" | "defaultChecked" | "required" | keyof ControllerRenderProps
|
||||||
> & {
|
> & {
|
||||||
rules?: ComponentProps<typeof Controller>["rules"];
|
rules?: ComponentProps<typeof Controller>["rules"];
|
||||||
@@ -39,6 +40,7 @@ type Props<T extends Record<string, unknown>> = Omit<
|
|||||||
label?: string;
|
label?: string;
|
||||||
defaultValue?: string[];
|
defaultValue?: string[];
|
||||||
createDeleteText?: (value: string) => string;
|
createDeleteText?: (value: string) => string;
|
||||||
|
optionFactory?: (val: any) => Option<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +58,8 @@ function ControlledChipInputField<T extends Record<string, unknown>>({
|
|||||||
placeholder,
|
placeholder,
|
||||||
className,
|
className,
|
||||||
createDeleteText,
|
createDeleteText,
|
||||||
|
children,
|
||||||
|
optionFactory = defaultOptionFactory,
|
||||||
...props
|
...props
|
||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
const { control, t, readOnly: formReadonly } = useScmFormContext();
|
const { control, t, readOnly: formReadonly } = useScmFormContext();
|
||||||
@@ -73,12 +77,14 @@ function ControlledChipInputField<T extends Record<string, unknown>>({
|
|||||||
name={nameWithPrefix}
|
name={nameWithPrefix}
|
||||||
rules={rules}
|
rules={rules}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field: { value, onChange, ...field }, fieldState }) => (
|
||||||
<ChipInputField
|
<ChipInputField
|
||||||
label={labelTranslation}
|
label={labelTranslation}
|
||||||
helpText={helpTextTranslation}
|
helpText={helpTextTranslation}
|
||||||
placeholder={placeholderTranslation}
|
placeholder={placeholderTranslation}
|
||||||
aria-label={ariaLabelTranslation}
|
aria-label={ariaLabelTranslation}
|
||||||
|
value={value ? value.map(optionFactory) : []}
|
||||||
|
onChange={(selectedOptions) => onChange(selectedOptions.map((item) => item.value))}
|
||||||
{...props}
|
{...props}
|
||||||
{...field}
|
{...field}
|
||||||
readOnly={readOnly ?? formReadonly}
|
readOnly={readOnly ?? formReadonly}
|
||||||
@@ -89,7 +95,9 @@ function ControlledChipInputField<T extends Record<string, unknown>>({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
testId={testId ?? `input-${nameWithPrefix}`}
|
testId={testId ?? `input-${nameWithPrefix}`}
|
||||||
/>
|
>
|
||||||
|
{children}
|
||||||
|
</ChipInputField>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
96
scm-ui/ui-forms/src/combobox/Combobox.stories.tsx
Normal file
96
scm-ui/ui-forms/src/combobox/Combobox.stories.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { storiesOf } from "@storybook/react";
|
||||||
|
import React, { Fragment, useState } from "react";
|
||||||
|
import Combobox from "./Combobox";
|
||||||
|
import { Combobox as HeadlessCombobox } from "@headlessui/react";
|
||||||
|
import { Option } from "@scm-manager/ui-types";
|
||||||
|
|
||||||
|
const waitFor = (ms: number) =>
|
||||||
|
function <T>(result: T) {
|
||||||
|
return new Promise<T>((resolve) => setTimeout(() => resolve(result), ms));
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = [
|
||||||
|
{ label: "Trillian", value: "1" },
|
||||||
|
{ label: "Arthur", value: "2" },
|
||||||
|
{ label: "Zaphod", value: "3" },
|
||||||
|
];
|
||||||
|
|
||||||
|
storiesOf("Combobox", module)
|
||||||
|
.add("Options array", () => {
|
||||||
|
const [value, setValue] = useState<Option<string>>();
|
||||||
|
return <Combobox options={data} value={value} onChange={setValue} />;
|
||||||
|
})
|
||||||
|
.add("Options function", () => {
|
||||||
|
const [value, setValue] = useState<Option<string>>();
|
||||||
|
return <Combobox options={() => data} value={value} onChange={setValue} />;
|
||||||
|
})
|
||||||
|
.add("Options function as promise", () => {
|
||||||
|
const [value, setValue] = useState<Option<string>>();
|
||||||
|
return (
|
||||||
|
<Combobox
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
options={(query) => Promise.resolve(data.filter((t) => t.label.startsWith(query))).then(waitFor(1000))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.add("Children as Element", () => {
|
||||||
|
const [value, setValue] = useState<Option<string>>();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox value={value} onChange={setValue} onQueryChange={setQuery}>
|
||||||
|
{query ? (
|
||||||
|
<HeadlessCombobox.Option value={{ label: query, value: query }} key={query} as={Fragment}>
|
||||||
|
{({ active }) => <Combobox.Option isActive={active}>{`Create ${query}`}</Combobox.Option>}
|
||||||
|
</HeadlessCombobox.Option>
|
||||||
|
) : null}
|
||||||
|
<HeadlessCombobox.Option value={{ label: "All", value: "All" }} key="all" as={Fragment}>
|
||||||
|
{({ active }) => <Combobox.Option isActive={active}>All</Combobox.Option>}
|
||||||
|
</HeadlessCombobox.Option>
|
||||||
|
<>
|
||||||
|
{data.map((o) => (
|
||||||
|
<HeadlessCombobox.Option value={o} key={o.value} as={Fragment}>
|
||||||
|
{({ active }) => <Combobox.Option isActive={active}>{o.label}</Combobox.Option>}
|
||||||
|
</HeadlessCombobox.Option>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
</Combobox>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.add("Children as render props", () => {
|
||||||
|
const [value, setValue] = useState<Option<string>>();
|
||||||
|
return (
|
||||||
|
<Combobox options={data} value={value} onChange={setValue}>
|
||||||
|
{(o) => (
|
||||||
|
<HeadlessCombobox.Option value={o} key={o.value} as={Fragment}>
|
||||||
|
{({ active }) => <Combobox.Option isActive={active}>{o.label}</Combobox.Option>}
|
||||||
|
</HeadlessCombobox.Option>
|
||||||
|
)}
|
||||||
|
</Combobox>
|
||||||
|
);
|
||||||
|
});
|
||||||
217
scm-ui/ui-forms/src/combobox/Combobox.tsx
Normal file
217
scm-ui/ui-forms/src/combobox/Combobox.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
ForwardedRef,
|
||||||
|
Fragment,
|
||||||
|
KeyboardEvent,
|
||||||
|
KeyboardEventHandler,
|
||||||
|
ReactElement,
|
||||||
|
Ref,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Combobox as HeadlessCombobox } from "@headlessui/react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { withForwardRef } from "../helpers";
|
||||||
|
import { createAttributesForTesting } from "@scm-manager/ui-components";
|
||||||
|
import { Option } from "@scm-manager/ui-types";
|
||||||
|
|
||||||
|
const OptionsWrapper = styled(HeadlessCombobox.Options).attrs({
|
||||||
|
className: "is-flex is-flex-direction-column has-rounded-border has-box-shadow is-overflow-hidden",
|
||||||
|
})`
|
||||||
|
z-index: 400;
|
||||||
|
top: 2.5rem;
|
||||||
|
border: var(--scm-border);
|
||||||
|
background-color: var(--scm-secondary-background);
|
||||||
|
max-width: 35ch;
|
||||||
|
|
||||||
|
&:empty {
|
||||||
|
border: 0;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
position: absolute;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledComboboxOption = styled.li.attrs({
|
||||||
|
className: "px-3 py-2 has-text-inherit is-clickable is-size-6 is-borderless has-background-transparent",
|
||||||
|
})<{ isActive: boolean }>`
|
||||||
|
line-height: inherit;
|
||||||
|
background-color: ${({ isActive }) => (isActive ? "var(--scm-column-selection)" : "")};
|
||||||
|
word-break: break-all;
|
||||||
|
&[data-disabled] {
|
||||||
|
color: unset !important;
|
||||||
|
opacity: 40%;
|
||||||
|
cursor: unset !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type BaseProps<T> = {
|
||||||
|
className?: string;
|
||||||
|
onKeyDown?: KeyboardEventHandler<HTMLElement>;
|
||||||
|
value?: Option<T> | null;
|
||||||
|
onChange?: (value?: Option<T>) => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
defaultValue?: Option<T>;
|
||||||
|
nullable?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
onQueryChange?: (value: string) => void;
|
||||||
|
id?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
"aria-describedby"?: string;
|
||||||
|
"aria-labelledby"?: string;
|
||||||
|
"aria-label"?: string;
|
||||||
|
testId?: string;
|
||||||
|
ref?: Ref<HTMLInputElement>;
|
||||||
|
form?: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ComboboxProps<T> =
|
||||||
|
| (BaseProps<T> & {
|
||||||
|
options: Array<Option<T>> | ((query: string) => Array<Option<T>> | Promise<Array<Option<T>>>);
|
||||||
|
children?: (option: Option<T>, index: number) => ReactElement;
|
||||||
|
})
|
||||||
|
| (BaseProps<T> & { children: Array<ReactElement | null>; options?: never });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @beta
|
||||||
|
* @since 2.45.0
|
||||||
|
*/
|
||||||
|
function ComboboxComponent<T>(props: ComboboxProps<T>, ref: ForwardedRef<HTMLInputElement>) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const { onQueryChange } = props;
|
||||||
|
|
||||||
|
useEffect(() => onQueryChange && onQueryChange(query), [onQueryChange, query]);
|
||||||
|
|
||||||
|
let options;
|
||||||
|
|
||||||
|
if (Array.isArray(props.children)) {
|
||||||
|
options = props.children;
|
||||||
|
} else if (typeof props.options === "function") {
|
||||||
|
options = <ComboboxOptions<T> children={props.children} options={props.options} query={query} />;
|
||||||
|
} else {
|
||||||
|
options = props.options?.map((o, index) =>
|
||||||
|
typeof props.children === "function" ? (
|
||||||
|
props.children(o, index)
|
||||||
|
) : (
|
||||||
|
<HeadlessCombobox.Option value={o} key={o.label} as={Fragment}>
|
||||||
|
{({ active }) => <StyledComboboxOption isActive={active}>{o.displayValue ?? o.label}</StyledComboboxOption>}
|
||||||
|
</HeadlessCombobox.Option>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HeadlessCombobox
|
||||||
|
as="div"
|
||||||
|
value={props.value}
|
||||||
|
onChange={(e?: Option<T>) => props.onChange && props.onChange(e)}
|
||||||
|
disabled={props.disabled || props.readOnly}
|
||||||
|
onKeyDown={(e: KeyboardEvent<HTMLElement>) => props.onKeyDown && props.onKeyDown(e)}
|
||||||
|
name={props.name}
|
||||||
|
form={props.form}
|
||||||
|
defaultValue={props.defaultValue}
|
||||||
|
// @ts-ignore
|
||||||
|
nullable={props.nullable}
|
||||||
|
className="is-relative is-flex-grow-1 is-flex"
|
||||||
|
by="value"
|
||||||
|
>
|
||||||
|
<HeadlessCombobox.Input<Option<T>>
|
||||||
|
displayValue={(o) => o?.label}
|
||||||
|
ref={ref}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
className={classNames(props.className, "is-full-width", "input", "is-ellipsis-overflow")}
|
||||||
|
aria-describedby={props["aria-describedby"]}
|
||||||
|
aria-labelledby={props["aria-labelledby"]}
|
||||||
|
aria-label={props["aria-label"]}
|
||||||
|
id={props.id}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
onBlur={props.onBlur}
|
||||||
|
{...createAttributesForTesting(props.testId)}
|
||||||
|
/>
|
||||||
|
<OptionsWrapper className="is-absolute">{options}</OptionsWrapper>
|
||||||
|
</HeadlessCombobox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComboboxOptionsProps<T> = {
|
||||||
|
options: (query: string) => Array<Option<T>> | Promise<Array<Option<T>>>;
|
||||||
|
children?: (option: Option<T>, index: number) => ReactElement;
|
||||||
|
query: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ComboboxOptions<T>({ query, options, children }: ComboboxOptionsProps<T>) {
|
||||||
|
const [optionsData, setOptionsData] = useState<Array<Option<T>>>([]);
|
||||||
|
const activePromise = useRef<Promise<Array<Option<T>>>>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const optionsExec = options(query);
|
||||||
|
if (optionsExec instanceof Promise) {
|
||||||
|
activePromise.current = optionsExec;
|
||||||
|
optionsExec
|
||||||
|
.then((newOptions) => {
|
||||||
|
if (activePromise.current === optionsExec) {
|
||||||
|
setOptionsData(newOptions);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (activePromise.current === optionsExec) {
|
||||||
|
setOptionsData([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setOptionsData(optionsExec);
|
||||||
|
}
|
||||||
|
}, [options, query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{optionsData?.map((o, index) =>
|
||||||
|
typeof children === "function" ? (
|
||||||
|
children(o, index)
|
||||||
|
) : (
|
||||||
|
<HeadlessCombobox.Option value={o} key={o.label} as={Fragment}>
|
||||||
|
{({ active }) => <StyledComboboxOption isActive={active}>{o.displayValue ?? o.label}</StyledComboboxOption>}
|
||||||
|
</HeadlessCombobox.Option>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Combobox = Object.assign(withForwardRef(ComboboxComponent), {
|
||||||
|
Option: StyledComboboxOption,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Combobox;
|
||||||
62
scm-ui/ui-forms/src/combobox/ComboboxField.tsx
Normal file
62
scm-ui/ui-forms/src/combobox/ComboboxField.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Field from "../base/Field";
|
||||||
|
import Label from "../base/label/Label";
|
||||||
|
import Help from "../base/help/Help";
|
||||||
|
import React from "react";
|
||||||
|
import { useGeneratedId } from "@scm-manager/ui-components";
|
||||||
|
import { withForwardRef } from "../helpers";
|
||||||
|
import Combobox, { ComboboxProps } from "./Combobox";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @beta
|
||||||
|
* @since 2.45.0
|
||||||
|
*/
|
||||||
|
const ComboboxField = function ComboboxField<T>(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
helpText,
|
||||||
|
error,
|
||||||
|
className,
|
||||||
|
isLoading,
|
||||||
|
...props
|
||||||
|
}: ComboboxProps<T> & { label: string; helpText?: string; error?: string; isLoading?: boolean },
|
||||||
|
ref: React.ForwardedRef<HTMLInputElement>
|
||||||
|
) {
|
||||||
|
const labelId = useGeneratedId();
|
||||||
|
return (
|
||||||
|
<Field className={className}>
|
||||||
|
<Label id={labelId}>
|
||||||
|
{label}
|
||||||
|
{helpText ? <Help className="ml-1" text={helpText} /> : null}
|
||||||
|
</Label>
|
||||||
|
<div className={classNames("control", { "is-loading": isLoading })}>
|
||||||
|
<Combobox {...props} ref={ref} aria-labelledby={labelId} />
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default withForwardRef(ComboboxField);
|
||||||
96
scm-ui/ui-forms/src/combobox/ControlledComboboxField.tsx
Normal file
96
scm-ui/ui-forms/src/combobox/ControlledComboboxField.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { ComponentProps } from "react";
|
||||||
|
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
|
||||||
|
import { useScmFormContext } from "../ScmFormContext";
|
||||||
|
import { useScmFormPathContext } from "../FormPathContext";
|
||||||
|
import { defaultOptionFactory, prefixWithoutIndices } from "../helpers";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import ComboboxField from "./ComboboxField";
|
||||||
|
import { Option } from "@scm-manager/ui-types";
|
||||||
|
|
||||||
|
type Props<T extends Record<string, unknown>> = Omit<
|
||||||
|
Parameters<typeof ComboboxField>[0],
|
||||||
|
"error" | "label" | keyof ControllerRenderProps
|
||||||
|
> & {
|
||||||
|
rules?: ComponentProps<typeof Controller>["rules"];
|
||||||
|
name: Path<T>;
|
||||||
|
label?: string;
|
||||||
|
optionFactory?: (val: any) => Option<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @beta
|
||||||
|
* @since 2.45.0
|
||||||
|
*/
|
||||||
|
function ControlledComboboxField<T extends Record<string, unknown>>({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
helpText,
|
||||||
|
rules,
|
||||||
|
testId,
|
||||||
|
defaultValue,
|
||||||
|
readOnly,
|
||||||
|
className,
|
||||||
|
optionFactory = defaultOptionFactory,
|
||||||
|
...props
|
||||||
|
}: Props<T>) {
|
||||||
|
const { control, t, readOnly: formReadonly, formId } = useScmFormContext();
|
||||||
|
const formPathPrefix = useScmFormPathContext();
|
||||||
|
const nameWithPrefix = formPathPrefix ? `${formPathPrefix}.${name}` : name;
|
||||||
|
const prefixedNameWithoutIndices = prefixWithoutIndices(nameWithPrefix);
|
||||||
|
const labelTranslation = label || t(`${prefixedNameWithoutIndices}.label`) || "";
|
||||||
|
const helpTextTranslation = helpText || t(`${prefixedNameWithoutIndices}.helpText`);
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={nameWithPrefix}
|
||||||
|
rules={rules}
|
||||||
|
defaultValue={defaultValue as never}
|
||||||
|
render={({ field: { onChange, value, ...field }, fieldState }) => (
|
||||||
|
// @ts-ignore
|
||||||
|
<ComboboxField
|
||||||
|
form={formId}
|
||||||
|
readOnly={readOnly ?? formReadonly}
|
||||||
|
className={classNames("column", className)}
|
||||||
|
{...props}
|
||||||
|
{...field}
|
||||||
|
label={labelTranslation}
|
||||||
|
helpText={helpTextTranslation}
|
||||||
|
onChange={(e) => onChange(e?.value)}
|
||||||
|
value={optionFactory(value)}
|
||||||
|
error={
|
||||||
|
fieldState.error
|
||||||
|
? fieldState.error.message || t(`${prefixedNameWithoutIndices}.error.${fieldState.error.type}`)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
testId={testId ?? `select-${nameWithPrefix}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ControlledComboboxField;
|
||||||
@@ -24,25 +24,71 @@
|
|||||||
|
|
||||||
import React, {
|
import React, {
|
||||||
ButtonHTMLAttributes,
|
ButtonHTMLAttributes,
|
||||||
|
ComponentType,
|
||||||
|
Context,
|
||||||
createContext,
|
createContext,
|
||||||
HTMLAttributes,
|
HTMLAttributes,
|
||||||
InputHTMLAttributes,
|
|
||||||
KeyboardEventHandler,
|
KeyboardEventHandler,
|
||||||
LiHTMLAttributes,
|
LiHTMLAttributes,
|
||||||
|
ReactElement,
|
||||||
|
RefObject,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { Option } from "@scm-manager/ui-types";
|
||||||
|
import { mergeRefs, withForwardRef } from "../helpers";
|
||||||
|
|
||||||
type ChipInputContextType = {
|
type ChipInputContextType<T> = {
|
||||||
add(newValue: string): void;
|
add(newValue: Option<T>): void;
|
||||||
remove(index: number): void;
|
remove(index: number): void;
|
||||||
|
inputRef: RefObject<HTMLInputElement>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
value: string[];
|
|
||||||
};
|
};
|
||||||
const ChipInputContext = createContext<ChipInputContextType>(null as unknown as ChipInputContextType);
|
|
||||||
|
const ChipInputContext = createContext<ChipInputContextType<never>>(null as unknown as ChipInputContextType<never>);
|
||||||
|
|
||||||
|
function getChipInputContext<T>() {
|
||||||
|
return ChipInputContext as unknown as Context<ChipInputContextType<T>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomNewChipInputProps<T> = {
|
||||||
|
onChange: (newValue: Option<T>) => void;
|
||||||
|
value?: Option<T> | null;
|
||||||
|
readOnly?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
ref?: React.Ref<HTMLInputElement>;
|
||||||
|
className?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
id?: string;
|
||||||
|
"aria-describedby"?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DefaultNewChipInput = React.forwardRef<HTMLInputElement, CustomNewChipInputProps<string>>(
|
||||||
|
({ onChange, value, ...props }, ref) => {
|
||||||
|
const handleKeyDown = useCallback<KeyboardEventHandler<HTMLInputElement>>(
|
||||||
|
(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.currentTarget.value) {
|
||||||
|
onChange({ label: e.currentTarget.value, value: e.currentTarget.value });
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="is-flex-grow-1">
|
||||||
|
<input onKeyDown={handleKeyDown} {...props} ref={ref} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
type ChipDeleteProps = {
|
type ChipDeleteProps = {
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
@@ -65,31 +111,33 @@ export const ChipDelete = React.forwardRef<HTMLButtonElement, ChipDeleteProps>((
|
|||||||
});
|
});
|
||||||
|
|
||||||
type NewChipInputProps = {
|
type NewChipInputProps = {
|
||||||
asChild?: boolean;
|
children?: ReactElement | null;
|
||||||
} & Omit<InputHTMLAttributes<HTMLInputElement>, "onKeyDown" | "disabled" | "readOnly">;
|
ref?: React.Ref<HTMLInputElement>;
|
||||||
|
className?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
id?: string;
|
||||||
|
"aria-describedby"?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @beta
|
* @beta
|
||||||
* @since 2.44.0
|
* @since 2.44.0
|
||||||
*/
|
*/
|
||||||
export const NewChipInput = React.forwardRef<HTMLInputElement, NewChipInputProps>(({ asChild, ...props }, ref) => {
|
export const NewChipInput = withForwardRef(function NewChipInput<T>(
|
||||||
const { add, value, disabled, readOnly } = useContext(ChipInputContext);
|
props: NewChipInputProps,
|
||||||
const handleKeyDown = useCallback<KeyboardEventHandler<HTMLInputElement>>(
|
ref: React.ForwardedRef<HTMLInputElement>
|
||||||
(e) => {
|
) {
|
||||||
if (e.key === "Enter") {
|
const { add, disabled, readOnly, inputRef } = useContext(getChipInputContext<T>());
|
||||||
e.preventDefault();
|
|
||||||
const newValue = e.currentTarget.value.trim();
|
const Comp = props.children ? Slot : DefaultNewChipInput;
|
||||||
if (newValue && !value?.includes(newValue)) {
|
return React.createElement(Comp as unknown as ComponentType<CustomNewChipInputProps<T>>, {
|
||||||
add(newValue);
|
...props,
|
||||||
e.currentTarget.value = "";
|
onChange: add,
|
||||||
}
|
readOnly,
|
||||||
return false;
|
disabled,
|
||||||
}
|
value: null,
|
||||||
},
|
ref: mergeRefs(ref, inputRef),
|
||||||
[add, value]
|
});
|
||||||
);
|
|
||||||
const Comp = asChild ? Slot : "input";
|
|
||||||
return <Comp {...props} onKeyDown={handleKeyDown} readOnly={readOnly} disabled={disabled} ref={ref} />;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type ChipProps = { asChild?: boolean } & LiHTMLAttributes<HTMLLIElement>;
|
type ChipProps = { asChild?: boolean } & LiHTMLAttributes<HTMLLIElement>;
|
||||||
@@ -104,48 +152,68 @@ export const Chip = React.forwardRef<HTMLLIElement, ChipProps>(({ asChild, ...pr
|
|||||||
return <Comp {...props} ref={ref} />;
|
return <Comp {...props} ref={ref} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
type Props = {
|
type Props<T> = {
|
||||||
value?: string[];
|
value?: Option<T>[] | null;
|
||||||
onChange?: (newValue: string[]) => void;
|
onChange?: (newValue?: Option<T>[]) => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
isNewItemDuplicate?: (existingItem: Option<T>, newItem: Option<T>) => boolean;
|
||||||
} & Omit<HTMLAttributes<HTMLUListElement>, "onChange">;
|
} & Omit<HTMLAttributes<HTMLUListElement>, "onChange">;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @beta
|
* @beta
|
||||||
* @since 2.44.0
|
* @since 2.44.0
|
||||||
*/
|
*/
|
||||||
const ChipInput = React.forwardRef<HTMLUListElement, Props>(
|
const ChipInput = withForwardRef(function ChipInput<T>(
|
||||||
({ children, value = [], disabled, readOnly, onChange, ...props }, ref) => {
|
{ children, value = [], disabled, readOnly, onChange, isNewItemDuplicate, ...props }: Props<T>,
|
||||||
|
ref: React.ForwardedRef<HTMLUListElement>
|
||||||
|
) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const isInactive = useMemo(() => disabled || readOnly, [disabled, readOnly]);
|
const isInactive = useMemo(() => disabled || readOnly, [disabled, readOnly]);
|
||||||
const add = useCallback(
|
const add = useCallback<(newValue: Option<T>) => void>(
|
||||||
(newValue: string) => !isInactive && onChange && onChange([...value, newValue]),
|
(newItem) => {
|
||||||
[isInactive, onChange, value]
|
if (
|
||||||
|
!isInactive &&
|
||||||
|
!value?.some((item) =>
|
||||||
|
isNewItemDuplicate
|
||||||
|
? isNewItemDuplicate(item, newItem)
|
||||||
|
: item.label === newItem.label || item.value === newItem.value
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (onChange) {
|
||||||
|
onChange([...(value ?? []), newItem]);
|
||||||
|
}
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isInactive, isNewItemDuplicate, onChange, value]
|
||||||
);
|
);
|
||||||
const remove = useCallback(
|
const remove = useCallback(
|
||||||
(index: number) => !isInactive && onChange && onChange(value?.filter((_, itdx) => itdx !== index)),
|
(index: number) => !isInactive && onChange && onChange(value?.filter((_, itdx) => itdx !== index)),
|
||||||
[isInactive, onChange, value]
|
[isInactive, onChange, value]
|
||||||
);
|
);
|
||||||
|
const Context = getChipInputContext<T>();
|
||||||
return (
|
return (
|
||||||
<ChipInputContext.Provider
|
<Context.Provider
|
||||||
value={useMemo(
|
value={useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
value,
|
|
||||||
disabled,
|
disabled,
|
||||||
readOnly,
|
readOnly,
|
||||||
add,
|
add,
|
||||||
remove,
|
remove,
|
||||||
|
inputRef,
|
||||||
}),
|
}),
|
||||||
[add, disabled, readOnly, remove, value]
|
[add, disabled, readOnly, remove]
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ul {...props} ref={ref}>
|
<ul {...props} ref={ref}>
|
||||||
{children}
|
{children}
|
||||||
</ul>
|
</ul>
|
||||||
</ChipInputContext.Provider>
|
</Context.Provider>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
export default Object.assign(ChipInput, {
|
export default Object.assign(ChipInput, {
|
||||||
Chip: Object.assign(Chip, {
|
Chip: Object.assign(Chip, {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { UseFormReturn } from "react-hook-form";
|
import { UseFormReturn } from "react-hook-form";
|
||||||
|
import { ForwardedRef, forwardRef, MutableRefObject, Ref, RefCallback } from "react";
|
||||||
|
|
||||||
export function prefixWithoutIndices(path: string): string {
|
export function prefixWithoutIndices(path: string): string {
|
||||||
return path.replace(/(\.\d+)/g, "");
|
return path.replace(/(\.\d+)/g, "");
|
||||||
@@ -49,3 +50,25 @@ export function setValues<T>(newValues: T, setValue: UseFormReturn<T>["setValue"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function withForwardRef<T extends { name: string }>(component: T): T {
|
||||||
|
return forwardRef(component as unknown as any) as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultOptionFactory = (item: any) =>
|
||||||
|
typeof item === "object" && item !== null && "value" in item && typeof item["label"] === "string"
|
||||||
|
? item
|
||||||
|
: { label: item as string, value: item };
|
||||||
|
|
||||||
|
export function mergeRefs<T>(...refs: Array<RefCallback<T> | MutableRefObject<T> | ForwardedRef<T>>) {
|
||||||
|
return (el: T) =>
|
||||||
|
refs.forEach((ref) => {
|
||||||
|
if (ref) {
|
||||||
|
if (typeof ref === "function") {
|
||||||
|
ref(el);
|
||||||
|
} else {
|
||||||
|
ref.current = el;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,10 +35,13 @@ import ControlledTable from "./table/ControlledTable";
|
|||||||
import ControlledColumn from "./table/ControlledColumn";
|
import ControlledColumn from "./table/ControlledColumn";
|
||||||
import AddListEntryForm from "./AddListEntryForm";
|
import AddListEntryForm from "./AddListEntryForm";
|
||||||
import { ScmNestedFormPathContextProvider } from "./FormPathContext";
|
import { ScmNestedFormPathContextProvider } from "./FormPathContext";
|
||||||
|
import ControlledComboboxField from "./combobox/ControlledComboboxField";
|
||||||
|
|
||||||
|
export { default as Combobox } from "./combobox/Combobox";
|
||||||
export { default as ConfigurationForm } from "./ConfigurationForm";
|
export { default as ConfigurationForm } from "./ConfigurationForm";
|
||||||
export { default as SelectField } from "./select/SelectField";
|
export { default as SelectField } from "./select/SelectField";
|
||||||
export { default as ChipInputField } from "./chip-input/ChipInputField";
|
export { default as ChipInputField } from "./chip-input/ChipInputField";
|
||||||
|
export { default as ComboboxField } from "./combobox/ComboboxField";
|
||||||
export { default as Select } from "./select/Select";
|
export { default as Select } from "./select/Select";
|
||||||
export * from "./resourceHooks";
|
export * from "./resourceHooks";
|
||||||
|
|
||||||
@@ -56,4 +59,5 @@ export const Form = Object.assign(FormCmp, {
|
|||||||
Column: ControlledColumn,
|
Column: ControlledColumn,
|
||||||
}),
|
}),
|
||||||
ChipInput: ControlledChipInputField,
|
ChipInput: ControlledChipInputField,
|
||||||
|
Combobox: ControlledComboboxField,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,6 +53,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.is-absolute {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
.has-box-shadow {
|
.has-box-shadow {
|
||||||
box-shadow: $box-shadow;
|
box-shadow: $box-shadow;
|
||||||
}
|
}
|
||||||
@@ -926,6 +932,10 @@ form .field:not(.is-grouped) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.is-overflow-hidden {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.is-overflow-visible {
|
.is-overflow-visible {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,14 @@
|
|||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Option } from "./Option";
|
||||||
|
|
||||||
export type AutocompleteObject = {
|
export type AutocompleteObject = {
|
||||||
id: string;
|
id: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SelectValue = {
|
/**
|
||||||
value: AutocompleteObject;
|
* @deprecated Use {@link Option} directly instead
|
||||||
label: string;
|
*/
|
||||||
};
|
export type SelectValue = Option<AutocompleteObject>;
|
||||||
|
|||||||
32
scm-ui/ui-types/src/Option.ts
Normal file
32
scm-ui/ui-types/src/Option.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Option<T> = {
|
||||||
|
label: string;
|
||||||
|
value: T;
|
||||||
|
/**
|
||||||
|
* Takes precedence over the label in alternative selection modes (i.e. popups in combo-boxes).
|
||||||
|
*/
|
||||||
|
displayValue?: string;
|
||||||
|
};
|
||||||
@@ -54,6 +54,8 @@ export * from "./Sources";
|
|||||||
|
|
||||||
export { SelectValue, AutocompleteObject } from "./Autocomplete";
|
export { SelectValue, AutocompleteObject } from "./Autocomplete";
|
||||||
|
|
||||||
|
export { Option } from "./Option";
|
||||||
|
|
||||||
export * from "./Plugin";
|
export * from "./Plugin";
|
||||||
|
|
||||||
export * from "./RepositoryRole";
|
export * from "./RepositoryRole";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "2.44.2-SNAPSHOT",
|
"version": "2.44.2-SNAPSHOT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^1.4.3",
|
"@headlessui/react": "^1.7.15",
|
||||||
"@scm-manager/ui-api": "2.44.2-SNAPSHOT",
|
"@scm-manager/ui-api": "2.44.2-SNAPSHOT",
|
||||||
"@scm-manager/ui-components": "2.44.2-SNAPSHOT",
|
"@scm-manager/ui-components": "2.44.2-SNAPSHOT",
|
||||||
"@scm-manager/ui-extensions": "2.44.2-SNAPSHOT",
|
"@scm-manager/ui-extensions": "2.44.2-SNAPSHOT",
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
"reset": "Leeren",
|
"reset": "Leeren",
|
||||||
"discardChanges": "Änderungen verwerfen",
|
"discardChanges": "Änderungen verwerfen",
|
||||||
"submit-success-notification": "Einstellungen wurden erfolgreich geändert!",
|
"submit-success-notification": "Einstellungen wurden erfolgreich geändert!",
|
||||||
|
"combobox": {
|
||||||
|
"arbitraryDisplayValue": "Verwende '{{query}}'"
|
||||||
|
},
|
||||||
"chipList": {
|
"chipList": {
|
||||||
"delete": "'{{item}}' löschen",
|
"delete": "'{{item}}' löschen",
|
||||||
"input": {
|
"input": {
|
||||||
|
|||||||
@@ -79,8 +79,8 @@
|
|||||||
"emergencyContacts": {
|
"emergencyContacts": {
|
||||||
"label": "Notfallkontakte",
|
"label": "Notfallkontakte",
|
||||||
"helpText": "Liste der Benutzer, die über administrative Vorfälle informiert werden.",
|
"helpText": "Liste der Benutzer, die über administrative Vorfälle informiert werden.",
|
||||||
"addButton": "Kontakt hinzufügen",
|
"autocompletePlaceholder": "Nutzer zum Benachrichtigen hinzufügen",
|
||||||
"autocompletePlaceholder": "Nutzer zum Benachrichtigen hinzufügen"
|
"ariaLabel": "Neuen Nutzer zum Benachrichtigen hinzufügen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
|
|||||||
@@ -401,6 +401,8 @@
|
|||||||
"permissions": "Berechtigung",
|
"permissions": "Berechtigung",
|
||||||
"group-permission": "Gruppenberechtigung",
|
"group-permission": "Gruppenberechtigung",
|
||||||
"user-permission": "Benutzerberechtigung",
|
"user-permission": "Benutzerberechtigung",
|
||||||
|
"group-select": "Gruppe auswählen",
|
||||||
|
"user-select": "Benutzer auswählen",
|
||||||
"edit-permission": {
|
"edit-permission": {
|
||||||
"delete-button": "Löschen",
|
"delete-button": "Löschen",
|
||||||
"save-button": "Änderungen speichern"
|
"save-button": "Änderungen speichern"
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
"reset": "Clear",
|
"reset": "Clear",
|
||||||
"discardChanges": "Discard changes",
|
"discardChanges": "Discard changes",
|
||||||
"submit-success-notification": "Configuration changed successfully!",
|
"submit-success-notification": "Configuration changed successfully!",
|
||||||
|
"combobox": {
|
||||||
|
"arbitraryDisplayValue": "Use '{{query}}'"
|
||||||
|
},
|
||||||
"chipList": {
|
"chipList": {
|
||||||
"delete": "Delete '{{item}}'",
|
"delete": "Delete '{{item}}'",
|
||||||
"input": {
|
"input": {
|
||||||
|
|||||||
@@ -79,8 +79,8 @@
|
|||||||
"emergencyContacts": {
|
"emergencyContacts": {
|
||||||
"label": "Emergency Contacts",
|
"label": "Emergency Contacts",
|
||||||
"helpText": "List of users notified of administrative incidents.",
|
"helpText": "List of users notified of administrative incidents.",
|
||||||
"addButton": "Add Contact",
|
"autocompletePlaceholder": "Add User to Notify",
|
||||||
"autocompletePlaceholder": "Add User to Notify"
|
"ariaLabel": "Add new user to emergency contacts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
|
|||||||
@@ -401,6 +401,8 @@
|
|||||||
"permissions": "Permissions",
|
"permissions": "Permissions",
|
||||||
"group-permission": "Group Permission",
|
"group-permission": "Group Permission",
|
||||||
"user-permission": "User Permission",
|
"user-permission": "User Permission",
|
||||||
|
"group-select": "Select group",
|
||||||
|
"user-select": "Select user",
|
||||||
"edit-permission": {
|
"edit-permission": {
|
||||||
"delete-button": "Delete",
|
"delete-button": "Delete",
|
||||||
"save-button": "Save Changes"
|
"save-button": "Save Changes"
|
||||||
|
|||||||
@@ -21,18 +21,14 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
import React, { FC } from "react";
|
import React, { FC, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useUserSuggestions } from "@scm-manager/ui-api";
|
import { useUserOptions, Option } from "@scm-manager/ui-api";
|
||||||
import { AnonymousMode, ConfigChangeHandler, NamespaceStrategies, SelectValue } from "@scm-manager/ui-types";
|
import { AnonymousMode, AutocompleteObject, ConfigChangeHandler, NamespaceStrategies } from "@scm-manager/ui-types";
|
||||||
import {
|
import { Checkbox, InputField, Select } from "@scm-manager/ui-components";
|
||||||
AutocompleteAddEntryToTableField,
|
|
||||||
Checkbox,
|
|
||||||
InputField,
|
|
||||||
MemberNameTagGroup,
|
|
||||||
Select
|
|
||||||
} from "@scm-manager/ui-components";
|
|
||||||
import NamespaceStrategySelect from "./NamespaceStrategySelect";
|
import NamespaceStrategySelect from "./NamespaceStrategySelect";
|
||||||
|
import { ChipInputField, Combobox } from "@scm-manager/ui-forms";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
realmDescription: string;
|
realmDescription: string;
|
||||||
@@ -68,10 +64,11 @@ const GeneralSettings: FC<Props> = ({
|
|||||||
namespaceStrategy,
|
namespaceStrategy,
|
||||||
namespaceStrategies,
|
namespaceStrategies,
|
||||||
onChange,
|
onChange,
|
||||||
hasUpdatePermission
|
hasUpdatePermission,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation("config");
|
const { t } = useTranslation("config");
|
||||||
const userSuggestions = useUserSuggestions();
|
const [query, setQuery] = useState("");
|
||||||
|
const { data: userOptions, isLoading: userOptionsLoading } = useUserOptions(query);
|
||||||
|
|
||||||
const handleLoginInfoUrlChange = (value: string) => {
|
const handleLoginInfoUrlChange = (value: string) => {
|
||||||
onChange(true, value, "loginInfoUrl");
|
onChange(true, value, "loginInfoUrl");
|
||||||
@@ -103,19 +100,12 @@ const GeneralSettings: FC<Props> = ({
|
|||||||
const handleEnabledApiKeysChange = (value: boolean) => {
|
const handleEnabledApiKeysChange = (value: boolean) => {
|
||||||
onChange(true, value, "enabledApiKeys");
|
onChange(true, value, "enabledApiKeys");
|
||||||
};
|
};
|
||||||
const handleEmergencyContactsChange = (p: string[]) => {
|
const handleEmergencyContactsChange = (p: Option<AutocompleteObject>[]) => {
|
||||||
onChange(true, p, "emergencyContacts");
|
onChange(
|
||||||
};
|
true,
|
||||||
|
p.map((c) => c.value.id),
|
||||||
const isMember = (name: string) => {
|
"emergencyContacts"
|
||||||
return emergencyContacts.includes(name);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const addEmergencyContact = (value: SelectValue) => {
|
|
||||||
if (isMember(value.value.id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleEmergencyContactsChange([...emergencyContacts, value.value.id]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -173,7 +163,7 @@ const GeneralSettings: FC<Props> = ({
|
|||||||
options={[
|
options={[
|
||||||
{ label: t("general-settings.anonymousMode.full"), value: "FULL" },
|
{ label: t("general-settings.anonymousMode.full"), value: "FULL" },
|
||||||
{ label: t("general-settings.anonymousMode.protocolOnly"), value: "PROTOCOL_ONLY" },
|
{ label: t("general-settings.anonymousMode.protocolOnly"), value: "PROTOCOL_ONLY" },
|
||||||
{ label: t("general-settings.anonymousMode.off"), value: "OFF" }
|
{ label: t("general-settings.anonymousMode.off"), value: "OFF" },
|
||||||
]}
|
]}
|
||||||
helpText={t("help.allowAnonymousAccessHelpText")}
|
helpText={t("help.allowAnonymousAccessHelpText")}
|
||||||
testId={"anonymous-mode-select"}
|
testId={"anonymous-mode-select"}
|
||||||
@@ -233,19 +223,20 @@ const GeneralSettings: FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="columns">
|
<div className="columns">
|
||||||
<div className="column is-full">
|
<div className="column is-full">
|
||||||
<MemberNameTagGroup
|
<ChipInputField<AutocompleteObject>
|
||||||
members={emergencyContacts}
|
|
||||||
memberListChanged={handleEmergencyContactsChange}
|
|
||||||
label={t("general-settings.emergencyContacts.label")}
|
label={t("general-settings.emergencyContacts.label")}
|
||||||
helpText={t("general-settings.emergencyContacts.helpText")}
|
helpText={t("general-settings.emergencyContacts.helpText")}
|
||||||
/>
|
|
||||||
<AutocompleteAddEntryToTableField
|
|
||||||
addEntry={addEmergencyContact}
|
|
||||||
buttonLabel={t("general-settings.emergencyContacts.addButton")}
|
|
||||||
loadSuggestions={userSuggestions}
|
|
||||||
placeholder={t("general-settings.emergencyContacts.autocompletePlaceholder")}
|
placeholder={t("general-settings.emergencyContacts.autocompletePlaceholder")}
|
||||||
helpText={t("general-settings.emergencyContacts.helpText")}
|
aria-label="general-settings.emergencyContacts.ariaLabel"
|
||||||
|
value={emergencyContacts.map((m) => ({ label: m, value: { id: m, displayName: m } }))}
|
||||||
|
onChange={handleEmergencyContactsChange}
|
||||||
|
>
|
||||||
|
<Combobox<AutocompleteObject>
|
||||||
|
options={userOptions || []}
|
||||||
|
className={classNames({ "is-loading": userOptionsLoading })}
|
||||||
|
onQueryChange={setQuery}
|
||||||
/>
|
/>
|
||||||
|
</ChipInputField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,42 +23,36 @@
|
|||||||
*/
|
*/
|
||||||
import React, { FC, FormEvent, useEffect, useState } from "react";
|
import React, { FC, FormEvent, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Group, Member, SelectValue } from "@scm-manager/ui-types";
|
import { AutocompleteObject, Group, Member, Option } from "@scm-manager/ui-types";
|
||||||
import {
|
import { Checkbox, InputField, Level, SubmitButton, Subtitle, Textarea } from "@scm-manager/ui-components";
|
||||||
AutocompleteAddEntryToTableField,
|
|
||||||
Checkbox,
|
|
||||||
InputField,
|
|
||||||
Level,
|
|
||||||
MemberNameTagGroup,
|
|
||||||
SubmitButton,
|
|
||||||
Subtitle,
|
|
||||||
Textarea
|
|
||||||
} from "@scm-manager/ui-components";
|
|
||||||
import * as validator from "./groupValidation";
|
import * as validator from "./groupValidation";
|
||||||
|
import { ChipInputField, Combobox } from "@scm-manager/ui-forms";
|
||||||
|
import { useUserOptions } from "@scm-manager/ui-api";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
submitForm: (p: Group) => void;
|
submitForm: (p: Group) => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
group?: Group;
|
group?: Group;
|
||||||
loadUserSuggestions: (p: string) => Promise<SelectValue[]>;
|
|
||||||
transmittedName?: string;
|
transmittedName?: string;
|
||||||
transmittedExternal?: boolean;
|
transmittedExternal?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const GroupForm: FC<Props> = ({ submitForm, loading, group, loadUserSuggestions, transmittedName = "", transmittedExternal = false }) => {
|
const GroupForm: FC<Props> = ({ submitForm, loading, group, transmittedName = "", transmittedExternal = false }) => {
|
||||||
const [t] = useTranslation("groups");
|
const [t] = useTranslation("groups");
|
||||||
const [groupState, setGroupState] = useState({
|
const [groupState, setGroupState] = useState({
|
||||||
name: transmittedName,
|
name: transmittedName,
|
||||||
description: "",
|
description: "",
|
||||||
_embedded: {
|
_embedded: {
|
||||||
members: [] as Member[]
|
members: [] as Member[],
|
||||||
},
|
},
|
||||||
_links: {},
|
_links: {},
|
||||||
members: [] as string[],
|
members: [] as string[],
|
||||||
type: "",
|
type: "",
|
||||||
external: transmittedExternal
|
external: transmittedExternal,
|
||||||
});
|
});
|
||||||
const [nameValidationError, setNameValidationError] = useState(false);
|
const [nameValidationError, setNameValidationError] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const { data: userOptions, isLoading: userOptionsLoading } = useUserOptions(query);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (group) {
|
if (group) {
|
||||||
@@ -84,21 +78,17 @@ const GroupForm: FC<Props> = ({ submitForm, loading, group, loadUserSuggestions,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ChipInputField<AutocompleteObject>
|
||||||
<MemberNameTagGroup
|
label={t("groupForm.addMemberAutocomplete.buttonLabel")}
|
||||||
members={groupState.members}
|
aria-label="groupForm.addMemberAutocomplete.ariaLabel"
|
||||||
memberListChanged={(memberNames: string[]) => setGroupState({ ...groupState, members: memberNames })}
|
|
||||||
/>
|
|
||||||
<AutocompleteAddEntryToTableField
|
|
||||||
addEntry={addMember}
|
|
||||||
disabled={false}
|
|
||||||
buttonLabel={t("groupForm.addMemberAutocomplete.buttonLabel")}
|
|
||||||
loadSuggestions={loadUserSuggestions}
|
|
||||||
placeholder={t("groupForm.addMemberAutocomplete.placeholder")}
|
placeholder={t("groupForm.addMemberAutocomplete.placeholder")}
|
||||||
loadingMessage={t("groupForm.addMemberAutocomplete.loading")}
|
value={groupState.members.map((m) => ({ label: m, value: { id: m, displayName: m } }))}
|
||||||
noOptionsMessage={t("groupForm.addMemberAutocomplete.noOptions")}
|
onChange={updateMembers}
|
||||||
/>
|
isLoading={userOptionsLoading}
|
||||||
</>
|
isNewItemDuplicate={(prev, cur) => prev.value.id === cur.value.id}
|
||||||
|
>
|
||||||
|
<Combobox<AutocompleteObject> options={userOptions || []} onQueryChange={setQuery} />
|
||||||
|
</ChipInputField>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,16 +101,13 @@ const GroupForm: FC<Props> = ({ submitForm, loading, group, loadUserSuggestions,
|
|||||||
label={t("group.external")}
|
label={t("group.external")}
|
||||||
checked={groupState.external}
|
checked={groupState.external}
|
||||||
helpText={t("groupForm.help.externalHelpText")}
|
helpText={t("groupForm.help.externalHelpText")}
|
||||||
onChange={external => setGroupState({ ...groupState, external })}
|
onChange={(external) => setGroupState({ ...groupState, external })}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addMember = (value: SelectValue) => {
|
const updateMembers = (members: Array<Option<AutocompleteObject>>) => {
|
||||||
if (groupState.members.includes(value.value.id)) {
|
setGroupState((prevState) => ({ ...prevState, members: members.map((m) => m.value.id) }));
|
||||||
return;
|
|
||||||
}
|
|
||||||
setGroupState({ ...groupState, members: [...groupState.members, value.value.id] });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let nameField = null;
|
let nameField = null;
|
||||||
@@ -131,7 +118,7 @@ const GroupForm: FC<Props> = ({ submitForm, loading, group, loadUserSuggestions,
|
|||||||
<InputField
|
<InputField
|
||||||
label={t("group.name")}
|
label={t("group.name")}
|
||||||
errorMessage={t("groupForm.nameError")}
|
errorMessage={t("groupForm.nameError")}
|
||||||
onChange={name => {
|
onChange={(name) => {
|
||||||
setNameValidationError(!validator.isNameValid(name));
|
setNameValidationError(!validator.isNameValid(name));
|
||||||
setGroupState({ ...groupState, name });
|
setGroupState({ ...groupState, name });
|
||||||
}}
|
}}
|
||||||
@@ -154,7 +141,7 @@ const GroupForm: FC<Props> = ({ submitForm, loading, group, loadUserSuggestions,
|
|||||||
<Textarea
|
<Textarea
|
||||||
label={t("group.description")}
|
label={t("group.description")}
|
||||||
errorMessage={t("groupForm.descriptionError")}
|
errorMessage={t("groupForm.descriptionError")}
|
||||||
onChange={description => setGroupState({ ...groupState, description })}
|
onChange={(description) => setGroupState({ ...groupState, description })}
|
||||||
value={groupState.description}
|
value={groupState.description}
|
||||||
validationError={false}
|
validationError={false}
|
||||||
helpText={t("groupForm.help.descriptionHelpText")}
|
helpText={t("groupForm.help.descriptionHelpText")}
|
||||||
|
|||||||
@@ -24,14 +24,13 @@
|
|||||||
import React, { FC } from "react";
|
import React, { FC } from "react";
|
||||||
import { Redirect, useLocation } from "react-router-dom";
|
import { Redirect, useLocation } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useCreateGroup, useUserSuggestions, urls } from "@scm-manager/ui-api";
|
import { useCreateGroup, urls } from "@scm-manager/ui-api";
|
||||||
import { Page } from "@scm-manager/ui-components";
|
import { Page } from "@scm-manager/ui-components";
|
||||||
import GroupForm from "../components/GroupForm";
|
import GroupForm from "../components/GroupForm";
|
||||||
|
|
||||||
const CreateGroup: FC = () => {
|
const CreateGroup: FC = () => {
|
||||||
const [t] = useTranslation("groups");
|
const [t] = useTranslation("groups");
|
||||||
const { isLoading, create, error, group } = useCreateGroup();
|
const { isLoading, create, error, group } = useCreateGroup();
|
||||||
const userSuggestions = useUserSuggestions();
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
if (group) {
|
if (group) {
|
||||||
@@ -44,7 +43,6 @@ const CreateGroup: FC = () => {
|
|||||||
<GroupForm
|
<GroupForm
|
||||||
submitForm={create}
|
submitForm={create}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
loadUserSuggestions={userSuggestions}
|
|
||||||
transmittedName={urls.getValueStringFromLocationByKey(location, "name")}
|
transmittedName={urls.getValueStringFromLocationByKey(location, "name")}
|
||||||
transmittedExternal={urls.getValueStringFromLocationByKey(location, "external") === "true"}
|
transmittedExternal={urls.getValueStringFromLocationByKey(location, "external") === "true"}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -35,13 +35,12 @@ type Props = {
|
|||||||
|
|
||||||
const EditGroup: FC<Props> = ({ group }) => {
|
const EditGroup: FC<Props> = ({ group }) => {
|
||||||
const { error, isLoading, update, isUpdated } = useUpdateGroup();
|
const { error, isLoading, update, isUpdated } = useUpdateGroup();
|
||||||
const userSuggestions = useUserSuggestions();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<UpdateNotification isUpdated={isUpdated} />
|
<UpdateNotification isUpdated={isUpdated} />
|
||||||
<ErrorNotification error={error || undefined} />
|
<ErrorNotification error={error || undefined} />
|
||||||
<GroupForm group={group} submitForm={update} loading={isLoading} loadUserSuggestions={userSuggestions} />
|
<GroupForm group={group} submitForm={update} loading={isLoading} />
|
||||||
<DeleteGroup group={group} />
|
<DeleteGroup group={group} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,12 +22,12 @@
|
|||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { FC } from "react";
|
import React, { FC, useState } from "react";
|
||||||
import { CUSTOM_NAMESPACE_STRATEGY } from "@scm-manager/ui-types";
|
import { CUSTOM_NAMESPACE_STRATEGY } from "@scm-manager/ui-types";
|
||||||
import { Autocomplete } from "@scm-manager/ui-components";
|
|
||||||
import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions";
|
import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNamespaceSuggestions } from "@scm-manager/ui-api";
|
import { ComboboxField } from "@scm-manager/ui-forms";
|
||||||
|
import { useNamespaceOptions } from "@scm-manager/ui-api";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
namespace: string;
|
namespace: string;
|
||||||
@@ -42,17 +42,18 @@ const NamespaceInput: FC<Props> = ({
|
|||||||
handleNamespaceChange,
|
handleNamespaceChange,
|
||||||
namespaceStrategy,
|
namespaceStrategy,
|
||||||
namespaceValidationError,
|
namespaceValidationError,
|
||||||
disabled
|
disabled,
|
||||||
}) => {
|
}) => {
|
||||||
const [t] = useTranslation("repos");
|
const [t] = useTranslation("repos");
|
||||||
const loadNamespaceSuggestions = useNamespaceSuggestions();
|
const [query, setQuery] = useState("");
|
||||||
|
const { isLoading, data } = useNamespaceOptions(query);
|
||||||
|
|
||||||
let informationMessage = undefined;
|
let informationMessage = undefined;
|
||||||
if (namespace?.indexOf(" ") > 0) {
|
if (namespace?.indexOf(" ") > 0) {
|
||||||
informationMessage = t("validation.namespaceSpaceWarningText");
|
informationMessage = t("validation.namespaceSpaceWarningText");
|
||||||
}
|
}
|
||||||
|
|
||||||
const repositorySelectValue = namespace ? { value: { id: namespace, displayName: "" }, label: namespace } : undefined;
|
const repositorySelectValue = namespace ? { value: { id: namespace, displayName: "" }, label: namespace } : null;
|
||||||
const props = {
|
const props = {
|
||||||
label: t("repository.namespace"),
|
label: t("repository.namespace"),
|
||||||
helpText: t("help.namespaceHelpText"),
|
helpText: t("help.namespaceHelpText"),
|
||||||
@@ -61,18 +62,21 @@ const NamespaceInput: FC<Props> = ({
|
|||||||
errorMessage: namespaceValidationError ? t("validation.namespace-invalid") : "",
|
errorMessage: namespaceValidationError ? t("validation.namespace-invalid") : "",
|
||||||
informationMessage: informationMessage,
|
informationMessage: informationMessage,
|
||||||
validationError: namespaceValidationError,
|
validationError: namespaceValidationError,
|
||||||
disabled: disabled
|
disabled: disabled,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (namespaceStrategy === CUSTOM_NAMESPACE_STRATEGY) {
|
if (namespaceStrategy === CUSTOM_NAMESPACE_STRATEGY) {
|
||||||
return (
|
return (
|
||||||
<Autocomplete
|
// @ts-ignore
|
||||||
{...props}
|
<ComboboxField
|
||||||
loadSuggestions={loadNamespaceSuggestions}
|
label={props.label}
|
||||||
|
helpText={props.helpText}
|
||||||
value={repositorySelectValue}
|
value={repositorySelectValue}
|
||||||
valueSelected={namespaceValue => handleNamespaceChange(namespaceValue.value.id)}
|
onChange={(option) => handleNamespaceChange(option?.value.id ?? "")}
|
||||||
placeholder={""}
|
onQueryChange={setQuery}
|
||||||
creatable={true}
|
options={data}
|
||||||
|
isLoading={isLoading}
|
||||||
|
nullable
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,30 +24,30 @@
|
|||||||
import React, { FC, FormEvent, useEffect, useState } from "react";
|
import React, { FC, FormEvent, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
|
AutocompleteObject,
|
||||||
Link,
|
Link,
|
||||||
Namespace,
|
Namespace,
|
||||||
PermissionCollection,
|
PermissionCollection,
|
||||||
PermissionCreateEntry,
|
PermissionCreateEntry,
|
||||||
Repository,
|
Repository,
|
||||||
RepositoryRole,
|
RepositoryRole,
|
||||||
SelectValue
|
|
||||||
} from "@scm-manager/ui-types";
|
} from "@scm-manager/ui-types";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ErrorNotification,
|
ErrorNotification,
|
||||||
GroupAutocomplete,
|
|
||||||
LabelWithHelpIcon,
|
LabelWithHelpIcon,
|
||||||
Level,
|
Level,
|
||||||
Radio,
|
Radio,
|
||||||
SubmitButton,
|
SubmitButton,
|
||||||
Subtitle,
|
Subtitle,
|
||||||
UserAutocomplete
|
|
||||||
} from "@scm-manager/ui-components";
|
} from "@scm-manager/ui-components";
|
||||||
import * as validator from "../utils/permissionValidation";
|
import * as validator from "../utils/permissionValidation";
|
||||||
import RoleSelector from "../components/RoleSelector";
|
import RoleSelector from "../components/RoleSelector";
|
||||||
import AdvancedPermissionsDialog from "./AdvancedPermissionsDialog";
|
import AdvancedPermissionsDialog from "./AdvancedPermissionsDialog";
|
||||||
import { useCreatePermission, useIndexLinks } from "@scm-manager/ui-api";
|
import { useCreatePermission, useGroupOptions, useIndexLinks, useUserOptions, Option } from "@scm-manager/ui-api";
|
||||||
import findVerbsForRole from "../utils/findVerbsForRole";
|
import findVerbsForRole from "../utils/findVerbsForRole";
|
||||||
|
import { ComboboxField } from "@scm-manager/ui-forms";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
availableRoles: RepositoryRole[];
|
availableRoles: RepositoryRole[];
|
||||||
@@ -58,29 +58,33 @@ type Props = {
|
|||||||
|
|
||||||
type PermissionState = PermissionCreateEntry & {
|
type PermissionState = PermissionCreateEntry & {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
value?: SelectValue;
|
value?: Option<AutocompleteObject>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useAutoCompleteLinks = () => {
|
const useAutoCompleteLinks = () => {
|
||||||
const links = useIndexLinks()?.autocomplete as Link[];
|
const links = useIndexLinks()?.autocomplete as Link[];
|
||||||
return {
|
return {
|
||||||
groups: links?.find(l => l.name === "groups"),
|
groups: links?.find((l) => l.name === "groups"),
|
||||||
users: links?.find(l => l.name === "users")
|
users: links?.find((l) => l.name === "users"),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isRepository = (namespaceOrRepository: Namespace | Repository): namespaceOrRepository is Repository => {
|
||||||
|
return (namespaceOrRepository as Repository).name !== undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const CreatePermissionForm: FC<Props> = ({
|
const CreatePermissionForm: FC<Props> = ({
|
||||||
availableRoles,
|
availableRoles,
|
||||||
availableVerbs,
|
availableVerbs,
|
||||||
currentPermissions,
|
currentPermissions,
|
||||||
namespaceOrRepository
|
namespaceOrRepository,
|
||||||
}) => {
|
}) => {
|
||||||
const initialPermissionState = {
|
const initialPermissionState = {
|
||||||
name: "",
|
name: "",
|
||||||
role: "READ",
|
role: "READ",
|
||||||
verbs: [],
|
verbs: [],
|
||||||
groupPermission: false,
|
groupPermission: false,
|
||||||
valid: false
|
valid: false,
|
||||||
};
|
};
|
||||||
const links = useAutoCompleteLinks();
|
const links = useAutoCompleteLinks();
|
||||||
const { isLoading, error, create, permission: createdPermission } = useCreatePermission(namespaceOrRepository);
|
const { isLoading, error, create, permission: createdPermission } = useCreatePermission(namespaceOrRepository);
|
||||||
@@ -92,23 +96,29 @@ const CreatePermissionForm: FC<Props> = ({
|
|||||||
...initialPermissionState,
|
...initialPermissionState,
|
||||||
groupPermission: createdPermission ? createdPermission.groupPermission : initialPermissionState.groupPermission,
|
groupPermission: createdPermission ? createdPermission.groupPermission : initialPermissionState.groupPermission,
|
||||||
role: createdPermission ? createdPermission.role : initialPermissionState.role,
|
role: createdPermission ? createdPermission.role : initialPermissionState.role,
|
||||||
verbs: createdPermission ? createdPermission?.verbs : initialPermissionState.verbs
|
verbs: createdPermission ? createdPermission?.verbs : initialPermissionState.verbs,
|
||||||
});
|
});
|
||||||
//eslint-disable-next-line
|
//eslint-disable-next-line
|
||||||
}, [createdPermission]);
|
}, [createdPermission]);
|
||||||
const selectedVerbs = permission.role ? findVerbsForRole(availableRoles, permission.role) : permission.verbs;
|
const selectedVerbs = permission.role ? findVerbsForRole(availableRoles, permission.role) : permission.verbs;
|
||||||
|
const [userQuery, setUserQuery] = useState("");
|
||||||
|
const [groupQuery, setGroupQuery] = useState("");
|
||||||
|
const { data: userOptions, isLoading: userOptionsLoading } = useUserOptions(userQuery);
|
||||||
|
const { data: groupOptions, isLoading: groupOptionsLoading } = useGroupOptions(groupQuery);
|
||||||
|
|
||||||
const selectName = (value: SelectValue) => {
|
const selectName = (value?: Option<AutocompleteObject>) => {
|
||||||
setPermission({
|
if (value) {
|
||||||
...permission,
|
setPermission((prevState) => ({
|
||||||
|
...prevState,
|
||||||
value,
|
value,
|
||||||
name: value.value.id,
|
name: value.value.id,
|
||||||
valid: validator.isPermissionValid(
|
valid: validator.isPermissionValid(
|
||||||
value.value.id,
|
value.value.id,
|
||||||
permission.groupPermission,
|
permission.groupPermission,
|
||||||
currentPermissions._embedded?.permissions || []
|
currentPermissions._embedded?.permissions || []
|
||||||
)
|
),
|
||||||
});
|
}));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const groupPermissionScopeChanged = (value: boolean) => {
|
const groupPermissionScopeChanged = (value: boolean) => {
|
||||||
@@ -126,7 +136,7 @@ const CreatePermissionForm: FC<Props> = ({
|
|||||||
const permissionScopeChanged = (groupPermission: boolean) => {
|
const permissionScopeChanged = (groupPermission: boolean) => {
|
||||||
setPermission({
|
setPermission({
|
||||||
...permission,
|
...permission,
|
||||||
groupPermission
|
groupPermission,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,7 +148,7 @@ const CreatePermissionForm: FC<Props> = ({
|
|||||||
setPermission({
|
setPermission({
|
||||||
...permission,
|
...permission,
|
||||||
verbs: [],
|
verbs: [],
|
||||||
role
|
role,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -146,7 +156,7 @@ const CreatePermissionForm: FC<Props> = ({
|
|||||||
setPermission({
|
setPermission({
|
||||||
...permission,
|
...permission,
|
||||||
role: undefined,
|
role: undefined,
|
||||||
verbs
|
verbs,
|
||||||
});
|
});
|
||||||
setShowAdvancedDialog(false);
|
setShowAdvancedDialog(false);
|
||||||
};
|
};
|
||||||
@@ -157,7 +167,7 @@ const CreatePermissionForm: FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const findAvailableRole = (roleName: string) => {
|
const findAvailableRole = (roleName: string) => {
|
||||||
return availableRoles.find(role => role.name === roleName);
|
return availableRoles.find((role) => role.name === roleName);
|
||||||
};
|
};
|
||||||
|
|
||||||
const empty = { value: { id: "", displayName: "" }, label: "" };
|
const empty = { value: { id: "", displayName: "" }, label: "" };
|
||||||
@@ -173,6 +183,7 @@ const CreatePermissionForm: FC<Props> = ({
|
|||||||
onClose={() => setShowAdvancedDialog(false)}
|
onClose={() => setShowAdvancedDialog(false)}
|
||||||
onSubmit={submitAdvancedPermissionsDialog}
|
onSubmit={submitAdvancedPermissionsDialog}
|
||||||
readOnly={!create}
|
readOnly={!create}
|
||||||
|
entityType={isRepository(namespaceOrRepository) ? "repository" : "namespace"}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<ErrorNotification error={error} />
|
<ErrorNotification error={error} />
|
||||||
@@ -198,17 +209,23 @@ const CreatePermissionForm: FC<Props> = ({
|
|||||||
<div className="columns">
|
<div className="columns">
|
||||||
<div className="column is-half">
|
<div className="column is-half">
|
||||||
{permission.groupPermission && links.groups ? (
|
{permission.groupPermission && links.groups ? (
|
||||||
<GroupAutocomplete
|
<ComboboxField<AutocompleteObject>
|
||||||
autocompleteLink={links.groups.href}
|
label={t("permission.group-select")}
|
||||||
valueSelected={selectName}
|
options={groupOptions || []}
|
||||||
|
onChange={selectName}
|
||||||
|
onQueryChange={setGroupQuery}
|
||||||
value={permission.value || empty}
|
value={permission.value || empty}
|
||||||
|
className={classNames({ "is-loading": groupOptionsLoading })}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!permission.groupPermission && links.users ? (
|
{!permission.groupPermission && links.users ? (
|
||||||
<UserAutocomplete
|
<ComboboxField<AutocompleteObject>
|
||||||
autocompleteLink={links.users.href}
|
label={t("permission.user-select")}
|
||||||
valueSelected={selectName}
|
options={userOptions || []}
|
||||||
|
onQueryChange={setUserQuery}
|
||||||
|
onChange={selectName}
|
||||||
value={permission.value || empty}
|
value={permission.value || empty}
|
||||||
|
className={classNames({ "is-loading": userOptionsLoading })}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -216,7 +233,7 @@ const CreatePermissionForm: FC<Props> = ({
|
|||||||
<div className="columns">
|
<div className="columns">
|
||||||
<div className="column is-narrow">
|
<div className="column is-narrow">
|
||||||
<RoleSelector
|
<RoleSelector
|
||||||
availableRoles={availableRoles.map(r => r.name)}
|
availableRoles={availableRoles.map((r) => r.name)}
|
||||||
label={t("permission.role")}
|
label={t("permission.role")}
|
||||||
helpText={t("permission.help.roleHelpText")}
|
helpText={t("permission.help.roleHelpText")}
|
||||||
handleRoleChange={handleRoleChange}
|
handleRoleChange={handleRoleChange}
|
||||||
|
|||||||
12
yarn.lock
12
yarn.lock
@@ -1722,6 +1722,13 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.6.6.tgz#3073c066b85535c9d28783da0a4d9288b5354d0c"
|
resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.6.6.tgz#3073c066b85535c9d28783da0a4d9288b5354d0c"
|
||||||
integrity sha512-MFJtmj9Xh/hhBMhLccGbBoSk+sk61BlP6sJe4uQcVMtXZhCgGqd2GyIQzzmsdPdTEWGSF434CBi8mnhR6um46Q==
|
integrity sha512-MFJtmj9Xh/hhBMhLccGbBoSk+sk61BlP6sJe4uQcVMtXZhCgGqd2GyIQzzmsdPdTEWGSF434CBi8mnhR6um46Q==
|
||||||
|
|
||||||
|
"@headlessui/react@^1.7.15":
|
||||||
|
version "1.7.15"
|
||||||
|
resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.7.15.tgz#53ef6ae132af81b8f188414767b6e79ebf8dc73f"
|
||||||
|
integrity sha512-OTO0XtoRQ6JPB1cKNFYBZv2Q0JMqMGNhYP1CjPvcJvjz8YGokz8oAj89HIYZGN0gZzn/4kk9iUpmMF4Q21Gsqw==
|
||||||
|
dependencies:
|
||||||
|
client-only "^0.0.1"
|
||||||
|
|
||||||
"@humanwhocodes/config-array@^0.5.0":
|
"@humanwhocodes/config-array@^0.5.0":
|
||||||
version "0.5.0"
|
version "0.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9"
|
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9"
|
||||||
@@ -8250,6 +8257,11 @@ cli-truncate@^2.1.0:
|
|||||||
slice-ansi "^3.0.0"
|
slice-ansi "^3.0.0"
|
||||||
string-width "^4.2.0"
|
string-width "^4.2.0"
|
||||||
|
|
||||||
|
client-only@^0.0.1:
|
||||||
|
version "0.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1"
|
||||||
|
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
|
||||||
|
|
||||||
clipboard@^2.0.0:
|
clipboard@^2.0.0:
|
||||||
version "2.0.11"
|
version "2.0.11"
|
||||||
resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.11.tgz#62180360b97dd668b6b3a84ec226975762a70be5"
|
resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.11.tgz#62180360b97dd668b6b3a84ec226975762a70be5"
|
||||||
|
|||||||
Reference in New Issue
Block a user