diff --git a/gradle/changelog/combobox.yaml b/gradle/changelog/combobox.yaml new file mode 100644 index 0000000000..763705c0be --- /dev/null +++ b/gradle/changelog/combobox.yaml @@ -0,0 +1,4 @@ +- type: added + description: New accessible Combobox component +- type: changed + description: Replace outdated `Autocomplete` component with new combobox diff --git a/scm-ui/ui-api/package.json b/scm-ui/ui-api/package.json index 7c716ebcf9..3b0ae66863 100644 --- a/scm-ui/ui-api/package.json +++ b/scm-ui/ui-api/package.json @@ -35,7 +35,8 @@ "query-string": "6.14.1", "react": "^17.0.1", "react-query": "^3.25.1", - "react-router-dom": "^5.3.1" + "react-router-dom": "^5.3.1", + "react-i18next": "11" }, "babel": { "presets": [ @@ -52,4 +53,4 @@ "publishConfig": { "access": "public" } -} \ No newline at end of file +} diff --git a/scm-ui/ui-api/src/index.ts b/scm-ui/ui-api/src/index.ts index 8da3143010..f54569732c 100644 --- a/scm-ui/ui-api/src/index.ts +++ b/scm-ui/ui-api/src/index.ts @@ -65,6 +65,7 @@ export * from "./usePluginCenterAuthInfo"; export * from "./compare"; export * from "./utils"; export * from "./links"; +export { useNamespaceOptions, useGroupOptions, useUserOptions } from "./useAutocompleteOptions"; export { default as ApiProvider } from "./ApiProvider"; export * from "./ApiProvider"; diff --git a/scm-ui/ui-api/src/useAutocompleteOptions.ts b/scm-ui/ui-api/src/useAutocompleteOptions.ts new file mode 100644 index 0000000000..02a6fc3148 --- /dev/null +++ b/scm-ui/ui-api/src/useAutocompleteOptions.ts @@ -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[], Error>( + ["options", link, query], + () => + apiClient + .get(link + "?q=" + query) + .then((r) => r.json()) + .then((json: Array) => { + const result: Array> = 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 }), + }); +}; diff --git a/scm-ui/ui-components/src/Autocomplete.tsx b/scm-ui/ui-components/src/Autocomplete.tsx index d138a064a9..2bca3594f0 100644 --- a/scm-ui/ui-components/src/Autocomplete.tsx +++ b/scm-ui/ui-components/src/Autocomplete.tsx @@ -46,11 +46,17 @@ type Props = { type State = {}; +/** + * @deprecated + * @since 2.45.0 + * + * Use {@link Combobox} instead + */ class Autocomplete extends React.Component { static defaultProps = { placeholder: "Type here", loadingMessage: "Loading...", - noOptionsMessage: "No suggestion available" + noOptionsMessage: "No suggestion available", }; handleInputChange = (newValue: ValueType, action: ActionMeta) => { @@ -67,7 +73,7 @@ class Autocomplete extends React.Component { selectValue: ValueType, selectOptions: readonly SelectValue[] ): boolean => { - const isNotDuplicated = !selectOptions.map(option => option.label).includes(inputValue); + const isNotDuplicated = !selectOptions.map((option) => option.label).includes(inputValue); const isNotEmpty = inputValue !== ""; return isNotEmpty && isNotDuplicated; }; @@ -85,7 +91,7 @@ class Autocomplete extends React.Component { informationMessage, creatable, className, - disabled + disabled, } = this.props; const asyncProps = { @@ -99,7 +105,7 @@ class Autocomplete extends React.Component { loadingMessage: () => loadingMessage, noOptionsMessage: () => noOptionsMessage, isDisabled: disabled, - "aria-label": helpText || label + "aria-label": helpText || label, }; return ( @@ -110,13 +116,13 @@ class Autocomplete extends React.Component { { + onCreateOption={(newValue) => { this.selectValue({ label: newValue, value: { id: newValue, - displayName: newValue - } + displayName: newValue, + }, }); }} /> diff --git a/scm-ui/ui-components/src/GroupAutocomplete.tsx b/scm-ui/ui-components/src/GroupAutocomplete.tsx index 2b407ad94b..f6f7e5e629 100644 --- a/scm-ui/ui-components/src/GroupAutocomplete.tsx +++ b/scm-ui/ui-components/src/GroupAutocomplete.tsx @@ -25,6 +25,12 @@ import React, { FC } from "react"; import UserGroupAutocomplete, { AutocompleteProps } from "./UserGroupAutocomplete"; import { useTranslation } from "react-i18next"; +/** + * @deprecated + * @since 2.45.0 + * + * Use {@link Combobox} instead + */ const GroupAutocomplete: FC = (props) => { const [t] = useTranslation("commons"); return ( diff --git a/scm-ui/ui-components/src/UserAutocomplete.tsx b/scm-ui/ui-components/src/UserAutocomplete.tsx index d618c7f3da..89a8dded6f 100644 --- a/scm-ui/ui-components/src/UserAutocomplete.tsx +++ b/scm-ui/ui-components/src/UserAutocomplete.tsx @@ -25,6 +25,12 @@ import React, { FC } from "react"; import { useTranslation } from "react-i18next"; import UserGroupAutocomplete, { AutocompleteProps } from "./UserGroupAutocomplete"; +/** + * @deprecated + * @since 2.45.0 + * + * Use {@link Combobox} instead + */ const UserAutocomplete: FC = (props) => { const [t] = useTranslation("commons"); return ( diff --git a/scm-ui/ui-components/src/UserGroupAutocomplete.tsx b/scm-ui/ui-components/src/UserGroupAutocomplete.tsx index 2eae39c1fd..313759c8e4 100644 --- a/scm-ui/ui-components/src/UserGroupAutocomplete.tsx +++ b/scm-ui/ui-components/src/UserGroupAutocomplete.tsx @@ -39,6 +39,12 @@ type Props = AutocompleteProps & { placeholder: string; }; +/** + * @deprecated + * @since 2.45.0 + * + * Use {@link Combobox} instead + */ const UserGroupAutocomplete: FC = ({ autocompleteLink, valueSelected, ...props }) => { const loadSuggestions = useSuggestions(autocompleteLink); diff --git a/scm-ui/ui-components/src/useDateFormatter.ts b/scm-ui/ui-components/src/useDateFormatter.ts index b73e8d4774..3a0208e070 100644 --- a/scm-ui/ui-components/src/useDateFormatter.ts +++ b/scm-ui/ui-components/src/useDateFormatter.ts @@ -43,7 +43,7 @@ type Options = { timeZone?: string; }; -export const chooseLocale = (language: string, languages?: string[]) => { +export const chooseLocale = (language: string, languages?: readonly string[]) => { for (const lng of languages || []) { const locale = supportedLocales[lng]; if (locale) { diff --git a/scm-ui/ui-forms/package.json b/scm-ui/ui-forms/package.json index e8db23918c..656141215b 100644 --- a/scm-ui/ui-forms/package.json +++ b/scm-ui/ui-forms/package.json @@ -44,6 +44,7 @@ "@scm-manager/ui-buttons": "2.44.2-SNAPSHOT", "@scm-manager/ui-overlays": "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-visually-hidden": "^1.0.3" }, @@ -54,4 +55,4 @@ "publishConfig": { "access": "public" } -} \ No newline at end of file +} diff --git a/scm-ui/ui-forms/src/Form.stories.tsx b/scm-ui/ui-forms/src/Form.stories.tsx index 8efeda280c..821b6dd8bb 100644 --- a/scm-ui/ui-forms/src/Form.stories.tsx +++ b/scm-ui/ui-forms/src/Form.stories.tsx @@ -37,6 +37,9 @@ import AddListEntryForm from "./AddListEntryForm"; import { ScmFormListContextProvider } from "./ScmFormListContext"; import { HalRepresentation } from "@scm-manager/ui-types"; import ControlledChipInputField from "./chip-input/ControlledChipInputField"; +import Combobox from "./combobox/Combobox"; +import ControlledComboboxField from "./combobox/ControlledComboboxField"; +import { defaultOptionFactory } from "./helpers"; export type SimpleWebHookConfiguration = { urlPattern: string; @@ -241,6 +244,8 @@ storiesOf("Forms", module) disableB: false, disableC: true, labels: ["test"], + people: [{ displayName: "Trillian", id: 2 }], + author: null, }} > @@ -250,6 +255,23 @@ storiesOf("Forms", module) + ({ label: person.displayName, value: person })} + > + + + )) .add("ReadOnly", () => ( diff --git a/scm-ui/ui-forms/src/chip-input/ChipInputField.stories.tsx b/scm-ui/ui-forms/src/chip-input/ChipInputField.stories.tsx index 08f92e1054..d2f28420bd 100644 --- a/scm-ui/ui-forms/src/chip-input/ChipInputField.stories.tsx +++ b/scm-ui/ui-forms/src/chip-input/ChipInputField.stories.tsx @@ -25,16 +25,45 @@ import { storiesOf } from "@storybook/react"; import React, { useState } from "react"; import ChipInputField from "./ChipInputField"; +import Combobox from "../combobox/Combobox"; +import { Option } from "@scm-manager/ui-types"; -storiesOf("Chip Input Field", module).add("Default", () => { - const [value, setValue] = useState(["test"]); - return ( - - ); -}); +storiesOf("Chip Input Field", module) + .add("Default", () => { + const [value, setValue] = useState[]>([]); + return ( + + ); + }) + .add("With Autocomplete", () => { + const people = ["Durward Reynolds", "Kenton Towne", "Therese Wunsch", "Benedict Kessler", "Katelyn Rohan"]; + + const [value, setValue] = useState[]>([]); + + return ( + + + Promise.resolve( + people + .map>((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}'` }) + ) + } + /> + + ); + }); diff --git a/scm-ui/ui-forms/src/chip-input/ChipInputField.tsx b/scm-ui/ui-forms/src/chip-input/ChipInputField.tsx index c161016741..a8a0a9ea9b 100644 --- a/scm-ui/ui-forms/src/chip-input/ChipInputField.tsx +++ b/scm-ui/ui-forms/src/chip-input/ChipInputField.tsx @@ -22,7 +22,7 @@ * SOFTWARE. */ -import React, { ComponentProps, useCallback } from "react"; +import React, { KeyboardEventHandler, ReactElement, useCallback } from "react"; import { createAttributesForTesting, useGeneratedId } from "@scm-manager/ui-components"; import Field from "../base/Field"; import Label from "../base/label/Label"; @@ -34,8 +34,10 @@ import { createVariantClass } from "../variants"; import ChipInput, { NewChipInput } from "../headless-chip-input/ChipInput"; import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; 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; height: min-content; gap: 0.5rem; @@ -48,79 +50,102 @@ const StyledChipInput = styled(ChipInput)` const StyledInput = styled(NewChipInput)` color: var(--scm-secondary-more-color); font-size: 1rem; + height: initial; + padding: 0; + border-radius: 0; &:focus { outline: none; } +` as unknown as typeof NewChipInput; + +const StyledDelete = styled(ChipInput.Chip.Delete)` + &:focus { + outline-offset: 0; + } `; -type InputFieldProps = { +type InputFieldProps = { label: string; createDeleteText?: (value: string) => string; helpText?: string; error?: string; testId?: string; id?: string; -} & Pick, "placeholder"> & - Pick, "onChange" | "value" | "readOnly" | "disabled"> & - Pick, "className">; + children?: ReactElement; + placeholder?: string; + onChange?: (newValue: Option[]) => void; + value?: Option[] | null; + onKeyDown?: KeyboardEventHandler; + readOnly?: boolean; + disabled?: boolean; + className?: string; + isLoading?: boolean; + isNewItemDuplicate?: (existingItem: Option, newItem: Option) => boolean; +}; /** * @beta * @since 2.44.0 */ -const ChipInputField = React.forwardRef( - ( - { - label, - helpText, - readOnly, - disabled, - error, - createDeleteText, - onChange, - placeholder, - value, - className, - testId, - id, - ...props - }, - ref - ) => { - const [t] = useTranslation("commons", { keyPrefix: "form.chipList" }); - const deleteTextCallback = useCallback( - (item) => (createDeleteText ? createDeleteText(item) : t("delete", { item })), - [createDeleteText, t] - ); - const inputId = useGeneratedId(id ?? testId); - const labelId = useGeneratedId(); - const inputDescriptionId = useGeneratedId(); - const variant = error ? "danger" : undefined; - return ( - - +const ChipInputField = function ChipInputField( + { + label, + helpText, + readOnly, + disabled, + error, + createDeleteText, + onChange, + placeholder, + value, + className, + testId, + id, + children, + isLoading, + isNewItemDuplicate, + ...props + }: InputFieldProps, + ref: React.ForwardedRef +) { + const [t] = useTranslation("commons", { keyPrefix: "form.chipList" }); + const deleteTextCallback = useCallback( + (item) => (createDeleteText ? createDeleteText(item) : t("delete", { item })), + [createDeleteText, t] + ); + const inputId = useGeneratedId(id ?? testId); + const labelId = useGeneratedId(); + const inputDescriptionId = useGeneratedId(); + const variant = error ? "danger" : undefined; + return ( + + +
onChange && onChange(e ?? [])} className="is-flex is-flex-wrap-wrap input" aria-labelledby={labelId} disabled={readOnly || disabled} + isNewItemDuplicate={isNewItemDuplicate} > - {value?.map((val, index) => ( - - {val} - + {value?.map((option, index) => ( + + {option.label} + ))} ( ref={ref} aria-describedby={inputDescriptionId} {...createAttributesForTesting(testId)} - /> + > + {children ? children : null} + - - {t("input.description")} - - {error ? {error} : null} - - ); - } -); -export default ChipInputField; +
+ + {t("input.description")} + + {error ? {error} : null} +
+ ); +}; +export default withForwardRef(ChipInputField); diff --git a/scm-ui/ui-forms/src/chip-input/ControlledChipInputField.tsx b/scm-ui/ui-forms/src/chip-input/ControlledChipInputField.tsx index bde24cc4b5..df01edf28a 100644 --- a/scm-ui/ui-forms/src/chip-input/ControlledChipInputField.tsx +++ b/scm-ui/ui-forms/src/chip-input/ControlledChipInputField.tsx @@ -26,12 +26,13 @@ import React, { ComponentProps } from "react"; import { Controller, ControllerRenderProps, Path } from "react-hook-form"; import { useScmFormContext } from "../ScmFormContext"; import { useScmFormPathContext } from "../FormPathContext"; -import { prefixWithoutIndices } from "../helpers"; +import { defaultOptionFactory, prefixWithoutIndices } from "../helpers"; import classNames from "classnames"; import ChipInputField from "./ChipInputField"; +import { Option } from "@scm-manager/ui-types"; type Props> = Omit< - ComponentProps, + Parameters[0], "error" | "createDeleteText" | "label" | "defaultChecked" | "required" | keyof ControllerRenderProps > & { rules?: ComponentProps["rules"]; @@ -39,6 +40,7 @@ type Props> = Omit< label?: string; defaultValue?: string[]; createDeleteText?: (value: string) => string; + optionFactory?: (val: any) => Option; }; /** @@ -56,6 +58,8 @@ function ControlledChipInputField>({ placeholder, className, createDeleteText, + children, + optionFactory = defaultOptionFactory, ...props }: Props) { const { control, t, readOnly: formReadonly } = useScmFormContext(); @@ -73,12 +77,14 @@ function ControlledChipInputField>({ name={nameWithPrefix} rules={rules} defaultValue={defaultValue} - render={({ field, fieldState }) => ( + render={({ field: { value, onChange, ...field }, fieldState }) => ( onChange(selectedOptions.map((item) => item.value))} {...props} {...field} readOnly={readOnly ?? formReadonly} @@ -89,7 +95,9 @@ function ControlledChipInputField>({ : undefined } testId={testId ?? `input-${nameWithPrefix}`} - /> + > + {children} + )} /> ); diff --git a/scm-ui/ui-forms/src/combobox/Combobox.stories.tsx b/scm-ui/ui-forms/src/combobox/Combobox.stories.tsx new file mode 100644 index 0000000000..9aef988efe --- /dev/null +++ b/scm-ui/ui-forms/src/combobox/Combobox.stories.tsx @@ -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 (result: T) { + return new Promise((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>(); + return ; + }) + .add("Options function", () => { + const [value, setValue] = useState>(); + return data} value={value} onChange={setValue} />; + }) + .add("Options function as promise", () => { + const [value, setValue] = useState>(); + return ( + Promise.resolve(data.filter((t) => t.label.startsWith(query))).then(waitFor(1000))} + /> + ); + }) + .add("Children as Element", () => { + const [value, setValue] = useState>(); + const [query, setQuery] = useState(""); + + return ( + + {query ? ( + + {({ active }) => {`Create ${query}`}} + + ) : null} + + {({ active }) => All} + + <> + {data.map((o) => ( + + {({ active }) => {o.label}} + + ))} + + + ); + }) + .add("Children as render props", () => { + const [value, setValue] = useState>(); + return ( + + {(o) => ( + + {({ active }) => {o.label}} + + )} + + ); + }); diff --git a/scm-ui/ui-forms/src/combobox/Combobox.tsx b/scm-ui/ui-forms/src/combobox/Combobox.tsx new file mode 100644 index 0000000000..7f547d1c5a --- /dev/null +++ b/scm-ui/ui-forms/src/combobox/Combobox.tsx @@ -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 = { + className?: string; + onKeyDown?: KeyboardEventHandler; + value?: Option | null; + onChange?: (value?: Option) => void; + onBlur?: () => void; + disabled?: boolean; + defaultValue?: Option; + 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; + form?: string; + name?: string; +}; + +export type ComboboxProps = + | (BaseProps & { + options: Array> | ((query: string) => Array> | Promise>>); + children?: (option: Option, index: number) => ReactElement; + }) + | (BaseProps & { children: Array; options?: never }); + +/** + * @beta + * @since 2.45.0 + */ +function ComboboxComponent(props: ComboboxProps, ref: ForwardedRef) { + 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 = children={props.children} options={props.options} query={query} />; + } else { + options = props.options?.map((o, index) => + typeof props.children === "function" ? ( + props.children(o, index) + ) : ( + + {({ active }) => {o.displayValue ?? o.label}} + + ) + ); + } + + return ( + ) => props.onChange && props.onChange(e)} + disabled={props.disabled || props.readOnly} + onKeyDown={(e: KeyboardEvent) => 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" + > + > + 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)} + /> + {options} + + ); +} + +type ComboboxOptionsProps = { + options: (query: string) => Array> | Promise>>; + children?: (option: Option, index: number) => ReactElement; + query: string; +}; + +function ComboboxOptions({ query, options, children }: ComboboxOptionsProps) { + const [optionsData, setOptionsData] = useState>>([]); + const activePromise = useRef>>>(); + + 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) + ) : ( + + {({ active }) => {o.displayValue ?? o.label}} + + ) + )} + + ); +} + +const Combobox = Object.assign(withForwardRef(ComboboxComponent), { + Option: StyledComboboxOption, +}); + +export default Combobox; diff --git a/scm-ui/ui-forms/src/combobox/ComboboxField.tsx b/scm-ui/ui-forms/src/combobox/ComboboxField.tsx new file mode 100644 index 0000000000..479f019465 --- /dev/null +++ b/scm-ui/ui-forms/src/combobox/ComboboxField.tsx @@ -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( + { + label, + helpText, + error, + className, + isLoading, + ...props + }: ComboboxProps & { label: string; helpText?: string; error?: string; isLoading?: boolean }, + ref: React.ForwardedRef +) { + const labelId = useGeneratedId(); + return ( + + +
+ +
+
+ ); +}; +export default withForwardRef(ComboboxField); diff --git a/scm-ui/ui-forms/src/combobox/ControlledComboboxField.tsx b/scm-ui/ui-forms/src/combobox/ControlledComboboxField.tsx new file mode 100644 index 0000000000..7e9d976fe3 --- /dev/null +++ b/scm-ui/ui-forms/src/combobox/ControlledComboboxField.tsx @@ -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> = Omit< + Parameters[0], + "error" | "label" | keyof ControllerRenderProps +> & { + rules?: ComponentProps["rules"]; + name: Path; + label?: string; + optionFactory?: (val: any) => Option; +}; + +/** + * @beta + * @since 2.45.0 + */ +function ControlledComboboxField>({ + name, + label, + helpText, + rules, + testId, + defaultValue, + readOnly, + className, + optionFactory = defaultOptionFactory, + ...props +}: Props) { + 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 ( + ( + // @ts-ignore + 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; diff --git a/scm-ui/ui-forms/src/headless-chip-input/ChipInput.tsx b/scm-ui/ui-forms/src/headless-chip-input/ChipInput.tsx index acf72dfd63..99b0c4b8b4 100644 --- a/scm-ui/ui-forms/src/headless-chip-input/ChipInput.tsx +++ b/scm-ui/ui-forms/src/headless-chip-input/ChipInput.tsx @@ -24,25 +24,71 @@ import React, { ButtonHTMLAttributes, + ComponentType, + Context, createContext, HTMLAttributes, - InputHTMLAttributes, KeyboardEventHandler, LiHTMLAttributes, + ReactElement, + RefObject, useCallback, useContext, useMemo, + useRef, } from "react"; import { Slot } from "@radix-ui/react-slot"; +import { Option } from "@scm-manager/ui-types"; +import { mergeRefs, withForwardRef } from "../helpers"; -type ChipInputContextType = { - add(newValue: string): void; +type ChipInputContextType = { + add(newValue: Option): void; remove(index: number): void; + inputRef: RefObject; disabled?: boolean; readOnly?: boolean; - value: string[]; }; -const ChipInputContext = createContext(null as unknown as ChipInputContextType); + +const ChipInputContext = createContext>(null as unknown as ChipInputContextType); + +function getChipInputContext() { + return ChipInputContext as unknown as Context>; +} + +type CustomNewChipInputProps = { + onChange: (newValue: Option) => void; + value?: Option | null; + readOnly?: boolean; + disabled?: boolean; + ref?: React.Ref; + className?: string; + placeholder?: string; + id?: string; + "aria-describedby"?: string; +}; + +const DefaultNewChipInput = React.forwardRef>( + ({ onChange, value, ...props }, ref) => { + const handleKeyDown = useCallback>( + (e) => { + if (e.key === "Enter") { + e.preventDefault(); + if (e.currentTarget.value) { + onChange({ label: e.currentTarget.value, value: e.currentTarget.value }); + } + return false; + } + }, + [onChange] + ); + + return ( +
+ +
+ ); + } +); type ChipDeleteProps = { asChild?: boolean; @@ -65,31 +111,33 @@ export const ChipDelete = React.forwardRef(( }); type NewChipInputProps = { - asChild?: boolean; -} & Omit, "onKeyDown" | "disabled" | "readOnly">; + children?: ReactElement | null; + ref?: React.Ref; + className?: string; + placeholder?: string; + id?: string; + "aria-describedby"?: string; +}; /** * @beta * @since 2.44.0 */ -export const NewChipInput = React.forwardRef(({ asChild, ...props }, ref) => { - const { add, value, disabled, readOnly } = useContext(ChipInputContext); - const handleKeyDown = useCallback>( - (e) => { - if (e.key === "Enter") { - e.preventDefault(); - const newValue = e.currentTarget.value.trim(); - if (newValue && !value?.includes(newValue)) { - add(newValue); - e.currentTarget.value = ""; - } - return false; - } - }, - [add, value] - ); - const Comp = asChild ? Slot : "input"; - return ; +export const NewChipInput = withForwardRef(function NewChipInput( + props: NewChipInputProps, + ref: React.ForwardedRef +) { + const { add, disabled, readOnly, inputRef } = useContext(getChipInputContext()); + + const Comp = props.children ? Slot : DefaultNewChipInput; + return React.createElement(Comp as unknown as ComponentType>, { + ...props, + onChange: add, + readOnly, + disabled, + value: null, + ref: mergeRefs(ref, inputRef), + }); }); type ChipProps = { asChild?: boolean } & LiHTMLAttributes; @@ -104,48 +152,68 @@ export const Chip = React.forwardRef(({ asChild, ...pr return ; }); -type Props = { - value?: string[]; - onChange?: (newValue: string[]) => void; +type Props = { + value?: Option[] | null; + onChange?: (newValue?: Option[]) => void; readOnly?: boolean; disabled?: boolean; + isNewItemDuplicate?: (existingItem: Option, newItem: Option) => boolean; } & Omit, "onChange">; /** * @beta * @since 2.44.0 */ -const ChipInput = React.forwardRef( - ({ children, value = [], disabled, readOnly, onChange, ...props }, ref) => { - const isInactive = useMemo(() => disabled || readOnly, [disabled, readOnly]); - const add = useCallback( - (newValue: string) => !isInactive && onChange && onChange([...value, newValue]), - [isInactive, onChange, value] - ); - const remove = useCallback( - (index: number) => !isInactive && onChange && onChange(value?.filter((_, itdx) => itdx !== index)), - [isInactive, onChange, value] - ); - return ( - ({ - value, - disabled, - readOnly, - add, - remove, - }), - [add, disabled, readOnly, remove, value] - )} - > -
    - {children} -
-
- ); - } -); +const ChipInput = withForwardRef(function ChipInput( + { children, value = [], disabled, readOnly, onChange, isNewItemDuplicate, ...props }: Props, + ref: React.ForwardedRef +) { + const inputRef = useRef(null); + const isInactive = useMemo(() => disabled || readOnly, [disabled, readOnly]); + const add = useCallback<(newValue: Option) => void>( + (newItem) => { + 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( + (index: number) => !isInactive && onChange && onChange(value?.filter((_, itdx) => itdx !== index)), + [isInactive, onChange, value] + ); + const Context = getChipInputContext(); + return ( + ({ + disabled, + readOnly, + add, + remove, + inputRef, + }), + [add, disabled, readOnly, remove] + )} + > +
    + {children} +
+
+ ); +}); export default Object.assign(ChipInput, { Chip: Object.assign(Chip, { diff --git a/scm-ui/ui-forms/src/helpers.ts b/scm-ui/ui-forms/src/helpers.ts index 822bb5d4c5..285dd17db4 100644 --- a/scm-ui/ui-forms/src/helpers.ts +++ b/scm-ui/ui-forms/src/helpers.ts @@ -23,6 +23,7 @@ */ import { UseFormReturn } from "react-hook-form"; +import { ForwardedRef, forwardRef, MutableRefObject, Ref, RefCallback } from "react"; export function prefixWithoutIndices(path: string): string { return path.replace(/(\.\d+)/g, ""); @@ -49,3 +50,25 @@ export function setValues(newValues: T, setValue: UseFormReturn["setValue" } } } + +export function withForwardRef(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(...refs: Array | MutableRefObject | ForwardedRef>) { + return (el: T) => + refs.forEach((ref) => { + if (ref) { + if (typeof ref === "function") { + ref(el); + } else { + ref.current = el; + } + } + }); +} diff --git a/scm-ui/ui-forms/src/index.ts b/scm-ui/ui-forms/src/index.ts index 47b2f53d23..c609b932ea 100644 --- a/scm-ui/ui-forms/src/index.ts +++ b/scm-ui/ui-forms/src/index.ts @@ -35,10 +35,13 @@ import ControlledTable from "./table/ControlledTable"; import ControlledColumn from "./table/ControlledColumn"; import AddListEntryForm from "./AddListEntryForm"; 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 SelectField } from "./select/SelectField"; export { default as ChipInputField } from "./chip-input/ChipInputField"; +export { default as ComboboxField } from "./combobox/ComboboxField"; export { default as Select } from "./select/Select"; export * from "./resourceHooks"; @@ -56,4 +59,5 @@ export const Form = Object.assign(FormCmp, { Column: ControlledColumn, }), ChipInput: ControlledChipInputField, + Combobox: ControlledComboboxField, }); diff --git a/scm-ui/ui-styles/src/components/_main.scss b/scm-ui/ui-styles/src/components/_main.scss index 744ebd113c..ca995aef30 100644 --- a/scm-ui/ui-styles/src/components/_main.scss +++ b/scm-ui/ui-styles/src/components/_main.scss @@ -53,6 +53,12 @@ } } + + +.is-absolute { + position: absolute; +} + .has-box-shadow { box-shadow: $box-shadow; } @@ -926,6 +932,10 @@ form .field:not(.is-grouped) { } } +.is-overflow-hidden { + overflow: hidden; +} + .is-overflow-visible { overflow: visible; } diff --git a/scm-ui/ui-types/src/Autocomplete.ts b/scm-ui/ui-types/src/Autocomplete.ts index 33607db776..398ebd09e6 100644 --- a/scm-ui/ui-types/src/Autocomplete.ts +++ b/scm-ui/ui-types/src/Autocomplete.ts @@ -22,12 +22,14 @@ * SOFTWARE. */ +import { Option } from "./Option"; + export type AutocompleteObject = { id: string; displayName?: string; }; -export type SelectValue = { - value: AutocompleteObject; - label: string; -}; +/** + * @deprecated Use {@link Option} directly instead + */ +export type SelectValue = Option; diff --git a/scm-ui/ui-types/src/Option.ts b/scm-ui/ui-types/src/Option.ts new file mode 100644 index 0000000000..88c1843dfc --- /dev/null +++ b/scm-ui/ui-types/src/Option.ts @@ -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 = { + label: string; + value: T; + /** + * Takes precedence over the label in alternative selection modes (i.e. popups in combo-boxes). + */ + displayValue?: string; +}; diff --git a/scm-ui/ui-types/src/index.ts b/scm-ui/ui-types/src/index.ts index 587e1f3fcd..4ca60cd07e 100644 --- a/scm-ui/ui-types/src/index.ts +++ b/scm-ui/ui-types/src/index.ts @@ -54,6 +54,8 @@ export * from "./Sources"; export { SelectValue, AutocompleteObject } from "./Autocomplete"; +export { Option } from "./Option"; + export * from "./Plugin"; export * from "./RepositoryRole"; diff --git a/scm-ui/ui-webapp/package.json b/scm-ui/ui-webapp/package.json index f67c947731..1328b096c6 100644 --- a/scm-ui/ui-webapp/package.json +++ b/scm-ui/ui-webapp/package.json @@ -3,7 +3,7 @@ "version": "2.44.2-SNAPSHOT", "private": true, "dependencies": { - "@headlessui/react": "^1.4.3", + "@headlessui/react": "^1.7.15", "@scm-manager/ui-api": "2.44.2-SNAPSHOT", "@scm-manager/ui-components": "2.44.2-SNAPSHOT", "@scm-manager/ui-extensions": "2.44.2-SNAPSHOT", @@ -73,4 +73,4 @@ "publishConfig": { "access": "public" } -} \ No newline at end of file +} diff --git a/scm-ui/ui-webapp/public/locales/de/commons.json b/scm-ui/ui-webapp/public/locales/de/commons.json index 2b97696bd2..f7f9b84f55 100644 --- a/scm-ui/ui-webapp/public/locales/de/commons.json +++ b/scm-ui/ui-webapp/public/locales/de/commons.json @@ -7,6 +7,9 @@ "reset": "Leeren", "discardChanges": "Änderungen verwerfen", "submit-success-notification": "Einstellungen wurden erfolgreich geändert!", + "combobox": { + "arbitraryDisplayValue": "Verwende '{{query}}'" + }, "chipList": { "delete": "'{{item}}' löschen", "input": { diff --git a/scm-ui/ui-webapp/public/locales/de/config.json b/scm-ui/ui-webapp/public/locales/de/config.json index 5e8398fde7..9961a5f261 100644 --- a/scm-ui/ui-webapp/public/locales/de/config.json +++ b/scm-ui/ui-webapp/public/locales/de/config.json @@ -79,8 +79,8 @@ "emergencyContacts": { "label": "Notfallkontakte", "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": { diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index a0a4805397..00712368c6 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -401,6 +401,8 @@ "permissions": "Berechtigung", "group-permission": "Gruppenberechtigung", "user-permission": "Benutzerberechtigung", + "group-select": "Gruppe auswählen", + "user-select": "Benutzer auswählen", "edit-permission": { "delete-button": "Löschen", "save-button": "Änderungen speichern" diff --git a/scm-ui/ui-webapp/public/locales/en/commons.json b/scm-ui/ui-webapp/public/locales/en/commons.json index 1e514639e2..8b7577f3d2 100644 --- a/scm-ui/ui-webapp/public/locales/en/commons.json +++ b/scm-ui/ui-webapp/public/locales/en/commons.json @@ -7,6 +7,9 @@ "reset": "Clear", "discardChanges": "Discard changes", "submit-success-notification": "Configuration changed successfully!", + "combobox": { + "arbitraryDisplayValue": "Use '{{query}}'" + }, "chipList": { "delete": "Delete '{{item}}'", "input": { diff --git a/scm-ui/ui-webapp/public/locales/en/config.json b/scm-ui/ui-webapp/public/locales/en/config.json index 136d8cabd1..b68d3684ee 100644 --- a/scm-ui/ui-webapp/public/locales/en/config.json +++ b/scm-ui/ui-webapp/public/locales/en/config.json @@ -79,8 +79,8 @@ "emergencyContacts": { "label": "Emergency Contacts", "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": { diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index b0935142a1..f27feb04d6 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -401,6 +401,8 @@ "permissions": "Permissions", "group-permission": "Group Permission", "user-permission": "User Permission", + "group-select": "Select group", + "user-select": "Select user", "edit-permission": { "delete-button": "Delete", "save-button": "Save Changes" diff --git a/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx b/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx index ad5f2de91b..ec9cb73369 100644 --- a/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx +++ b/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx @@ -21,18 +21,14 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React, { FC } from "react"; +import React, { FC, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useUserSuggestions } from "@scm-manager/ui-api"; -import { AnonymousMode, ConfigChangeHandler, NamespaceStrategies, SelectValue } from "@scm-manager/ui-types"; -import { - AutocompleteAddEntryToTableField, - Checkbox, - InputField, - MemberNameTagGroup, - Select -} from "@scm-manager/ui-components"; +import { useUserOptions, Option } from "@scm-manager/ui-api"; +import { AnonymousMode, AutocompleteObject, ConfigChangeHandler, NamespaceStrategies } from "@scm-manager/ui-types"; +import { Checkbox, InputField, Select } from "@scm-manager/ui-components"; import NamespaceStrategySelect from "./NamespaceStrategySelect"; +import { ChipInputField, Combobox } from "@scm-manager/ui-forms"; +import classNames from "classnames"; type Props = { realmDescription: string; @@ -68,10 +64,11 @@ const GeneralSettings: FC = ({ namespaceStrategy, namespaceStrategies, onChange, - hasUpdatePermission + hasUpdatePermission, }) => { const { t } = useTranslation("config"); - const userSuggestions = useUserSuggestions(); + const [query, setQuery] = useState(""); + const { data: userOptions, isLoading: userOptionsLoading } = useUserOptions(query); const handleLoginInfoUrlChange = (value: string) => { onChange(true, value, "loginInfoUrl"); @@ -103,19 +100,12 @@ const GeneralSettings: FC = ({ const handleEnabledApiKeysChange = (value: boolean) => { onChange(true, value, "enabledApiKeys"); }; - const handleEmergencyContactsChange = (p: string[]) => { - onChange(true, p, "emergencyContacts"); - }; - - const isMember = (name: string) => { - return emergencyContacts.includes(name); - }; - - const addEmergencyContact = (value: SelectValue) => { - if (isMember(value.value.id)) { - return; - } - handleEmergencyContactsChange([...emergencyContacts, value.value.id]); + const handleEmergencyContactsChange = (p: Option[]) => { + onChange( + true, + p.map((c) => c.value.id), + "emergencyContacts" + ); }; return ( @@ -173,7 +163,7 @@ const GeneralSettings: FC = ({ options={[ { label: t("general-settings.anonymousMode.full"), value: "FULL" }, { 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")} testId={"anonymous-mode-select"} @@ -233,19 +223,20 @@ const GeneralSettings: FC = ({
- label={t("general-settings.emergencyContacts.label")} 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} + > + + options={userOptions || []} + className={classNames({ "is-loading": userOptionsLoading })} + onQueryChange={setQuery} + /> +
diff --git a/scm-ui/ui-webapp/src/groups/components/GroupForm.tsx b/scm-ui/ui-webapp/src/groups/components/GroupForm.tsx index c59b58679a..3bfc0ba71a 100644 --- a/scm-ui/ui-webapp/src/groups/components/GroupForm.tsx +++ b/scm-ui/ui-webapp/src/groups/components/GroupForm.tsx @@ -23,42 +23,36 @@ */ import React, { FC, FormEvent, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Group, Member, SelectValue } from "@scm-manager/ui-types"; -import { - AutocompleteAddEntryToTableField, - Checkbox, - InputField, - Level, - MemberNameTagGroup, - SubmitButton, - Subtitle, - Textarea -} from "@scm-manager/ui-components"; +import { AutocompleteObject, Group, Member, Option } from "@scm-manager/ui-types"; +import { Checkbox, InputField, Level, SubmitButton, Subtitle, Textarea } from "@scm-manager/ui-components"; import * as validator from "./groupValidation"; +import { ChipInputField, Combobox } from "@scm-manager/ui-forms"; +import { useUserOptions } from "@scm-manager/ui-api"; type Props = { submitForm: (p: Group) => void; loading?: boolean; group?: Group; - loadUserSuggestions: (p: string) => Promise; transmittedName?: string; transmittedExternal?: boolean; }; -const GroupForm: FC = ({ submitForm, loading, group, loadUserSuggestions, transmittedName = "", transmittedExternal = false }) => { +const GroupForm: FC = ({ submitForm, loading, group, transmittedName = "", transmittedExternal = false }) => { const [t] = useTranslation("groups"); const [groupState, setGroupState] = useState({ name: transmittedName, description: "", _embedded: { - members: [] as Member[] + members: [] as Member[], }, _links: {}, members: [] as string[], type: "", - external: transmittedExternal + external: transmittedExternal, }); const [nameValidationError, setNameValidationError] = useState(false); + const [query, setQuery] = useState(""); + const { data: userOptions, isLoading: userOptionsLoading } = useUserOptions(query); useEffect(() => { if (group) { @@ -84,21 +78,17 @@ const GroupForm: FC = ({ submitForm, loading, group, loadUserSuggestions, } return ( - <> - setGroupState({ ...groupState, members: memberNames })} - /> - - + + label={t("groupForm.addMemberAutocomplete.buttonLabel")} + aria-label="groupForm.addMemberAutocomplete.ariaLabel" + placeholder={t("groupForm.addMemberAutocomplete.placeholder")} + value={groupState.members.map((m) => ({ label: m, value: { id: m, displayName: m } }))} + onChange={updateMembers} + isLoading={userOptionsLoading} + isNewItemDuplicate={(prev, cur) => prev.value.id === cur.value.id} + > + options={userOptions || []} onQueryChange={setQuery} /> +
); }; @@ -111,16 +101,13 @@ const GroupForm: FC = ({ submitForm, loading, group, loadUserSuggestions, label={t("group.external")} checked={groupState.external} helpText={t("groupForm.help.externalHelpText")} - onChange={external => setGroupState({ ...groupState, external })} + onChange={(external) => setGroupState({ ...groupState, external })} /> ); }; - const addMember = (value: SelectValue) => { - if (groupState.members.includes(value.value.id)) { - return; - } - setGroupState({ ...groupState, members: [...groupState.members, value.value.id] }); + const updateMembers = (members: Array>) => { + setGroupState((prevState) => ({ ...prevState, members: members.map((m) => m.value.id) })); }; let nameField = null; @@ -131,7 +118,7 @@ const GroupForm: FC = ({ submitForm, loading, group, loadUserSuggestions, { + onChange={(name) => { setNameValidationError(!validator.isNameValid(name)); setGroupState({ ...groupState, name }); }} @@ -154,7 +141,7 @@ const GroupForm: FC = ({ submitForm, loading, group, loadUserSuggestions,