From 8145d842099dfc75170e492c08042556ca13b9b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Bie=C5=84?= Date: Wed, 6 Nov 2024 16:42:38 +0100 Subject: [PATCH] refactor: fix bundle for ticket-fields --- assets/flash-notifications-bundle.js | 2 +- assets/new-request-form-bundle.js | 21 +- assets/service-catalog-bundle.js | 4 +- assets/shared-bundle.js | 2 +- assets/ticket-fields-bundle.js | 525 ++++++++++++++++++++++++++- assets/wysiwyg-bundle.js | 2 +- rollup.config.mjs | 5 +- templates/document_head.hbs | 5 +- 8 files changed, 536 insertions(+), 30 deletions(-) diff --git a/assets/flash-notifications-bundle.js b/assets/flash-notifications-bundle.js index 45013c90e..57318458c 100644 --- a/assets/flash-notifications-bundle.js +++ b/assets/flash-notifications-bundle.js @@ -1,4 +1,4 @@ -import { d as useToast, r as reactExports, j as jsxRuntimeExports, N as Notification, e as Title, f as Close, a2 as FLASH_NOTIFICATIONS_KEY, _ as reactDomExports, a0 as ThemeProviders, a1 as createTheme } from 'shared'; +import { k as useToast, r as reactExports, j as jsxRuntimeExports, N as Notification, T as Title, l as Close, a9 as FLASH_NOTIFICATIONS_KEY, a6 as reactDomExports, a7 as ThemeProviders, a8 as createTheme } from 'shared'; function FlashNotifications({ notifications, closeLabel, }) { const { addToast } = useToast(); diff --git a/assets/new-request-form-bundle.js b/assets/new-request-form-bundle.js index f6f866f72..3bae8edc2 100644 --- a/assets/new-request-form-bundle.js +++ b/assets/new-request-form-bundle.js @@ -1,5 +1,5 @@ -import { r as reactExports, j as jsxRuntimeExports, F as Field, L as Label, C as Combobox, O as Option, p as purify, s as styled, u as useTranslation, a as FileList, b as File, T as Tooltip, P as Progress, A as Anchor, d as useToast, N as Notification, e as Title, f as Close, m as mime, h as useDropzone, i as Field$1, k as Label$1, M as Message, l as FileUpload, I as Input, n as useGrid, o as focusStyles, q as FauxInput, t as Tag, H as Hint, S as Span, v as SvgAlertWarningStroke, $ as $e, w as getColorV8, x as Header, y as SvgCheckCircleStroke, z as useModalContainer, B as Modal, D as Body, E as Accordion, G as Paragraph, J as Footer$1, K as FooterItem, Q as Button, R as Close$1, U as addFlashNotification, V as Hint$1, W as Message$1, X as Alert, Y as initI18next, Z as loadTranslations, _ as reactDomExports, a0 as ThemeProviders, a1 as createTheme } from 'shared'; -import { E as EmptyValueOption, I as Input$1, T as TextArea, D as DatePicker, i as isCustomField, a as TicketFields } from 'index'; +import { r as reactExports, j as jsxRuntimeExports, c as Field, e as Label, g as Combobox, O as Option, p as purify, s as styled, u as useTranslation, q as FileList, t as File, v as Tooltip, P as Progress, A as Anchor, k as useToast, N as Notification, T as Title, l as Close, w as mime, x as useDropzone, F as Field$1, L as Label$1, M as Message, y as FileUpload, I as Input, z as useGrid, B as focusStyles, E as FauxInput, G as Tag, H as Hint, S as Span, J as SvgAlertWarningStroke, $ as $e, K as getColorV8, Q as Header, R as SvgCheckCircleStroke, U as useModalContainer, V as Modal, W as Body, X as Accordion, Y as Paragraph, Z as Footer$1, _ as FooterItem, a0 as Button, a1 as Close$1, a2 as addFlashNotification, a3 as Alert, a4 as initI18next, a5 as loadTranslations, a6 as reactDomExports, a7 as ThemeProviders, a8 as createTheme } from 'shared'; +import { I as Input$1, D as DropDown, T as TextArea, a as DatePicker, i as isCustomField, b as TicketFields } from 'ticket-fields'; const key = "return-focus-to-ticket-form-field"; function TicketFormField({ field, newRequestPath, }) { @@ -761,23 +761,6 @@ function AnswerBotModal({ authToken, interactionAccessToken, articles, requestId }, children: t("new-request-form.answer-bot-modal.solve-request", "Yes, close my request") }) })] }), jsxRuntimeExports.jsx(Close$1, { "aria-label": t("new-request-form.close-label", "Close") })] })); } -function DropDown({ field, onChange }) { - const { label, options, error, value, name, required, description } = field; - const selectionValue = value == null ? "" : value.toString(); - const wrapperRef = reactExports.useRef(null); - reactExports.useEffect(() => { - if (wrapperRef.current && required) { - const combobox = wrapperRef.current.querySelector("[role=combobox]"); - combobox?.setAttribute("aria-required", "true"); - } - }, [wrapperRef, required]); - return (jsxRuntimeExports.jsxs(Field, { children: [jsxRuntimeExports.jsxs(Label, { children: [label, required && jsxRuntimeExports.jsx(Span, { "aria-hidden": "true", children: "*" })] }), description && (jsxRuntimeExports.jsx(Hint$1, { dangerouslySetInnerHTML: { __html: description } })), jsxRuntimeExports.jsxs(Combobox, { ref: wrapperRef, inputProps: { name, required }, isEditable: false, validation: error ? "error" : undefined, inputValue: selectionValue, selectionValue: selectionValue, renderValue: ({ selection }) => selection?.label || jsxRuntimeExports.jsx(EmptyValueOption, {}), onChange: ({ selectionValue }) => { - if (selectionValue !== undefined) { - onChange(selectionValue); - } - }, children: [!required && (jsxRuntimeExports.jsx(Option, { value: "", label: "-", children: jsxRuntimeExports.jsx(EmptyValueOption, {}) })), options.map((option) => (jsxRuntimeExports.jsx(Option, { value: option.value.toString(), label: option.name }, option.value)))] }), error && jsxRuntimeExports.jsx(Message$1, { validation: "error", children: error })] })); -} - const StyledParagraph = styled(Paragraph) ` margin: ${(props) => props.theme.space.md} 0; `; diff --git a/assets/service-catalog-bundle.js b/assets/service-catalog-bundle.js index daaa3ee26..6cfbb0056 100644 --- a/assets/service-catalog-bundle.js +++ b/assets/service-catalog-bundle.js @@ -1,5 +1,5 @@ -import { s as styled, w as getColorV8, j as jsxRuntimeExports, a3 as SvgShapesFill, a4 as Grid, a5 as Col, a6 as Row, a7 as Skeleton, a8 as MD, a9 as SM, u as useTranslation, aa as LG, A as Anchor, Q as Button, r as reactExports, ab as CursorPagination, _ as reactDomExports, a0 as ThemeProviders, a1 as createTheme, ac as XXXL, ad as SvgChevronUpFill, ae as SvgChevronDownFill } from 'shared'; -import { g as getCustomObjectKey, a as TicketFields } from 'index'; +import { s as styled, K as getColorV8, j as jsxRuntimeExports, aa as SvgShapesFill, ab as Grid, ac as Col, ad as Row, ae as Skeleton, af as MD, ag as SM, u as useTranslation, ah as LG, A as Anchor, a0 as Button, r as reactExports, ai as CursorPagination, a6 as reactDomExports, a7 as ThemeProviders, a8 as createTheme, aj as XXXL, ak as SvgChevronUpFill, al as SvgChevronDownFill } from 'shared'; +import { g as getCustomObjectKey, b as TicketFields } from 'ticket-fields'; const ItemContainer = styled.a ` display: flex; diff --git a/assets/shared-bundle.js b/assets/shared-bundle.js index 4d9fb252b..a280175aa 100644 --- a/assets/shared-bundle.js +++ b/assets/shared-bundle.js @@ -37556,4 +37556,4 @@ var SvgChevronDownFill = function SvgChevronDownFill(props) { }))); }; -export { $e as $, Anchor as A, Modal as B, Combobox as C, Body as D, Accordion as E, Field as F, Paragraph as G, Hint$1 as H, Input as I, Footer as J, FooterItem as K, Label$1 as L, Message$1 as M, Notification as N, Option as O, Progress as P, Button as Q, Close as R, Span as S, Tooltip as T, addFlashNotification as U, Hint as V, Message as W, Alert as X, initI18next as Y, loadTranslations as Z, reactDomExports as _, FileList as a, ThemeProviders as a0, createTheme as a1, FLASH_NOTIFICATIONS_KEY as a2, SvgShapesFill as a3, Grid as a4, Col as a5, Row as a6, Skeleton as a7, MD as a8, SM as a9, LG as aa, CursorPagination as ab, XXXL as ac, SvgChevronUpFill as ad, SvgChevronDownFill as ae, Checkbox as af, MediaInput as ag, SvgCreditCardStroke as ah, Datepicker as ai, debounce$2 as aj, OptGroup as ak, Textarea as al, File as b, commonjsGlobal as c, useToast as d, Title as e, Close$3 as f, getDefaultExportFromCjs as g, useDropzone as h, Field$1 as i, jsxRuntimeExports as j, Label$2 as k, FileUpload as l, mime as m, useGrid as n, focusStyles as o, purify as p, FauxInput as q, reactExports as r, styled as s, Tag$1 as t, useTranslation as u, SvgAlertWarningStroke as v, getColorV8 as w, Header$1 as x, SvgCheckCircleStroke as y, useModalContainer as z }; +export { $e as $, Anchor as A, focusStyles as B, Checkbox as C, Datepicker as D, FauxInput as E, Field$1 as F, Tag$1 as G, Hint$1 as H, Input as I, SvgAlertWarningStroke as J, getColorV8 as K, Label$2 as L, Message$1 as M, Notification as N, Option as O, Progress as P, Header$1 as Q, SvgCheckCircleStroke as R, Span as S, Title as T, useModalContainer as U, Modal as V, Body as W, Accordion as X, Paragraph as Y, Footer as Z, FooterItem as _, MediaInput as a, Button as a0, Close as a1, addFlashNotification as a2, Alert as a3, initI18next as a4, loadTranslations as a5, reactDomExports as a6, ThemeProviders as a7, createTheme as a8, FLASH_NOTIFICATIONS_KEY as a9, SvgShapesFill as aa, Grid as ab, Col as ac, Row as ad, Skeleton as ae, MD as af, SM as ag, LG as ah, CursorPagination as ai, XXXL as aj, SvgChevronUpFill as ak, SvgChevronDownFill as al, SvgCreditCardStroke as b, Field as c, debounce$2 as d, Label$1 as e, Hint as f, Combobox as g, Message as h, OptGroup as i, jsxRuntimeExports as j, useToast as k, Close$3 as l, Textarea as m, getDefaultExportFromCjs as n, commonjsGlobal as o, purify as p, FileList as q, reactExports as r, styled as s, File as t, useTranslation as u, Tooltip as v, mime as w, useDropzone as x, FileUpload as y, useGrid as z }; diff --git a/assets/ticket-fields-bundle.js b/assets/ticket-fields-bundle.js index 9454dca77..48d82eae0 100644 --- a/assets/ticket-fields-bundle.js +++ b/assets/ticket-fields-bundle.js @@ -1,2 +1,523 @@ -import 'shared'; -export { a as TicketFields, i as isCustomField, s as systemType } from 'index'; +import { r as reactExports, j as jsxRuntimeExports, F as Field, C as Checkbox$1, L as Label, S as Span, H as Hint, M as Message, s as styled, u as useTranslation, a as MediaInput, b as SvgCreditCardStroke, D as Datepicker, I as Input$1, d as debounce, c as Field$1, e as Label$1, f as Hint$1, g as Combobox, O as Option, h as Message$1, i as OptGroup, k as useToast, N as Notification, T as Title, l as Close, m as Textarea } from 'shared'; + +function Checkbox({ field, onChange }) { + const { label, error, value, name, required, description } = field; + const [checkboxValue, setCheckboxValue] = reactExports.useState(value); + const handleChange = (e) => { + const { checked } = e.target; + setCheckboxValue(checked); + onChange(checked); + }; + return (jsxRuntimeExports.jsxs(Field, { children: [jsxRuntimeExports.jsx("input", { type: "hidden", name: name, value: "off" }), jsxRuntimeExports.jsxs(Checkbox$1, { name: name, required: required, defaultChecked: value, value: checkboxValue ? "on" : "off", onChange: handleChange, children: [jsxRuntimeExports.jsxs(Label, { children: [label, required && jsxRuntimeExports.jsx(Span, { "aria-hidden": "true", children: "*" })] }), description && (jsxRuntimeExports.jsx(Hint, { dangerouslySetInnerHTML: { __html: description } }))] }), error && jsxRuntimeExports.jsx(Message, { validation: "error", children: error })] })); +} + +/** + * When there is an error in the credit card field, the backend returns a redacted value with the last 4 digits prefixed with some Xs. + * This function removes the Xs from the value and returns the last 4 digits of the credit card + * + * @param value The value returned by the backend with last 4 digits prefixed with some Xs + * @returns The last 4 digits of the credit card + */ +function getLastDigits(value) { + return value ? value.replaceAll("X", "") : ""; +} +const DigitsHintSpan = styled(Span) ` + margin-left: ${(props) => props.theme.space.xxs}; + font-weight: ${(props) => props.theme.fontWeights.medium}; +`; +function CreditCard({ field, onChange }) { + const { t } = useTranslation(); + const { label, error, value, name, required, description } = field; + const digits = getLastDigits(value); + return (jsxRuntimeExports.jsxs(Field, { children: [jsxRuntimeExports.jsxs(Label, { children: [label, required && jsxRuntimeExports.jsx(Span, { "aria-hidden": "true", children: "*" }), jsxRuntimeExports.jsx(DigitsHintSpan, { children: t("new-request-form.credit-card-digits-hint", "(Last 4 digits)") })] }), description && (jsxRuntimeExports.jsx(Hint, { dangerouslySetInnerHTML: { __html: description } })), jsxRuntimeExports.jsx(MediaInput, { start: jsxRuntimeExports.jsx(SvgCreditCardStroke, {}), name: name, type: "text", value: digits, onChange: (e) => onChange(e.target.value), validation: error ? "error" : undefined, required: required, maxLength: 4, placeholder: "XXXX" }), error && jsxRuntimeExports.jsx(Message, { validation: "error", children: error })] })); +} + +function DatePicker({ field, locale, valueFormat, onChange, }) { + const { label, error, value, name, required, description } = field; + const [date, setDate] = reactExports.useState(value ? new Date(value) : undefined); + const formatDate = (value) => { + if (value === undefined) { + return ""; + } + const isoString = value.toISOString(); + return valueFormat === "dateTime" ? isoString : isoString.split("T")[0]; + }; + const handleChange = (date) => { + // Set the time to 12:00:00 as this is also the expected behavior across Support and the API + const newDate = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0)); + setDate(newDate); + const dateString = formatDate(newDate); + if (dateString !== undefined) { + onChange(dateString); + } + }; + const handleInputChange = (e) => { + // Allow field to be cleared + if (e.target.value === "") { + setDate(undefined); + onChange(""); + } + }; + return (jsxRuntimeExports.jsxs(Field, { children: [jsxRuntimeExports.jsxs(Label, { children: [label, required && jsxRuntimeExports.jsx(Span, { "aria-hidden": "true", children: "*" })] }), description && (jsxRuntimeExports.jsx(Hint, { dangerouslySetInnerHTML: { __html: description } })), jsxRuntimeExports.jsx(Datepicker, { value: date, onChange: handleChange, locale: locale, children: jsxRuntimeExports.jsx(Input$1, { required: required, lang: locale, onChange: handleInputChange, validation: error ? "error" : undefined }) }), error && jsxRuntimeExports.jsx(Message, { validation: "error", children: error }), jsxRuntimeExports.jsx("input", { type: "hidden", name: name, value: formatDate(date) })] })); +} + +function Input({ field, onChange }) { + const { label, error, value, name, required, description, type } = field; + const stepProp = {}; + const inputType = type === "integer" || type === "decimal" ? "number" : "text"; + if (type === "integer") + stepProp.step = "1"; + if (type === "decimal") + stepProp.step = "any"; + const autocomplete = type === "anonymous_requester_email" ? "email" : undefined; + return (jsxRuntimeExports.jsxs(Field, { children: [jsxRuntimeExports.jsxs(Label, { children: [label, required && jsxRuntimeExports.jsx(Span, { "aria-hidden": "true", children: "*" })] }), description && (jsxRuntimeExports.jsx(Hint, { dangerouslySetInnerHTML: { __html: description } })), jsxRuntimeExports.jsx(Input$1, { name: name, type: inputType, defaultValue: value, validation: error ? "error" : undefined, required: required, onChange: (e) => { + onChange && onChange(e.target.value); + }, autoComplete: autocomplete, ...stepProp }), error && jsxRuntimeExports.jsx(Message, { validation: "error", children: error })] })); +} + +function EmptyValueOption() { + const { t } = useTranslation(); + return (jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [jsxRuntimeExports.jsx(Span, { "aria-hidden": "true", children: "-" }), jsxRuntimeExports.jsx(Span, { hidden: true, children: t("ticket-fields.dropdown.empty-option", "Select an option") })] })); +} + +function getCustomObjectKey(targetType) { + return targetType.replace("zen:custom_object:", ""); +} +const EMPTY_OPTION = { + value: "", + name: "-", +}; +function LookupField({ field, userId, organizationId, onChange, }) { + const { id: fieldId, label, error, value, name, required, description, relationship_target_type, } = field; + const [options, setOptions] = reactExports.useState([]); + const [selectedOption, setSelectedOption] = reactExports.useState(null); + const [inputValue, setInputValue] = reactExports.useState(value); + const [isLoadingOptions, setIsLoadingOptions] = reactExports.useState(false); + const { t } = useTranslation(); + const customObjectKey = getCustomObjectKey(relationship_target_type); + const loadingOption = { + name: t("ticket-fields.lookup-field.loading-options", "Loading items..."), + id: "loading", + }; + const noResultsOption = { + name: t("ticket-fields.lookup-field.no-matches-found", "No matches found"), + id: "no-results", + }; + const fetchSelectedOption = reactExports.useCallback(async (selectionValue) => { + try { + const res = await fetch(`/api/v2/custom_objects/${customObjectKey}/records/${selectionValue}`); + if (res.ok) { + const { custom_object_record } = await res.json(); + const newSelectedOption = { + name: custom_object_record.name, + value: custom_object_record.id, + }; + setSelectedOption(newSelectedOption); + setInputValue(custom_object_record.name); + } + } + catch (error) { + console.error(error); + } + }, [customObjectKey]); + const fetchOptions = reactExports.useCallback(async (inputValue) => { + const searchParams = new URLSearchParams(); + searchParams.set("name", inputValue.toLocaleLowerCase()); + searchParams.set("source", "zen:ticket"); + searchParams.set("field_id", fieldId.toString()); + searchParams.set("requester_id", userId.toString()); + if (organizationId !== null) + searchParams.set("organization_id", organizationId); + setIsLoadingOptions(true); + try { + const response = await fetch(`/api/v2/custom_objects/${customObjectKey}/records/autocomplete?${searchParams.toString()}`); + const data = await response.json(); + if (response.ok) { + let fetchedOptions = data.custom_object_records.map(({ name, id }) => ({ + name, + value: id, + })); + if (selectedOption) { + fetchedOptions = fetchedOptions.filter((option) => option.value !== selectedOption.value); + fetchedOptions = [selectedOption, ...fetchedOptions]; + } + setOptions(fetchedOptions); + } + else { + setOptions([]); + } + } + catch (error) { + console.error(error); + } + finally { + setIsLoadingOptions(false); + } + }, [customObjectKey, fieldId, organizationId, selectedOption, userId]); + const debouncedFetchOptions = reactExports.useMemo(() => debounce(fetchOptions, 300), [fetchOptions]); + reactExports.useEffect(() => { + return () => debouncedFetchOptions.cancel(); + }, [debouncedFetchOptions]); + const handleChange = reactExports.useCallback(({ inputValue, selectionValue }) => { + if (selectionValue !== undefined) { + if (selectionValue == "") { + setSelectedOption(EMPTY_OPTION); + setInputValue(EMPTY_OPTION.name); + setOptions([]); + onChange(EMPTY_OPTION.value); + } + else { + const selectedOption = options.find((option) => option.value === selectionValue); + if (selectedOption) { + setInputValue(selectedOption.name); + setSelectedOption(selectedOption); + setOptions([selectedOption]); + onChange(selectedOption.value); + } + } + } + if (inputValue !== undefined) { + setInputValue(inputValue); + debouncedFetchOptions(inputValue); + } + }, [debouncedFetchOptions, onChange, options]); + reactExports.useEffect(() => { + if (value) { + fetchSelectedOption(value); + } + }, []); //we don't set dependency array as we want this hook to be called only once + const onFocus = () => { + setInputValue(""); + fetchOptions("*"); + }; + return (jsxRuntimeExports.jsxs(Field$1, { children: [jsxRuntimeExports.jsxs(Label$1, { children: [label, required && jsxRuntimeExports.jsx(Span, { "aria-hidden": "true", children: "*" })] }), description && (jsxRuntimeExports.jsx(Hint$1, { dangerouslySetInnerHTML: { __html: description } })), jsxRuntimeExports.jsxs(Combobox, { inputProps: { required }, "data-test-id": "lookup-field-combobox", validation: error ? "error" : undefined, inputValue: inputValue, selectionValue: selectedOption?.value, isAutocomplete: true, placeholder: t("ticket-fields.lookup-field.placeholder", "Search {{label}}", { label: label.toLowerCase() }), onFocus: onFocus, onChange: handleChange, renderValue: () => selectedOption ? selectedOption?.name : EMPTY_OPTION.name, children: [selectedOption?.name !== EMPTY_OPTION.name && (jsxRuntimeExports.jsx(Option, { value: "", label: "-", children: jsxRuntimeExports.jsx(EmptyValueOption, {}) })), isLoadingOptions && (jsxRuntimeExports.jsx(Option, { isDisabled: true, value: loadingOption.name }, loadingOption.id)), !isLoadingOptions && + inputValue?.length > 0 && + options.length === 0 && (jsxRuntimeExports.jsx(Option, { isDisabled: true, value: noResultsOption.name }, noResultsOption.id)), !isLoadingOptions && + options.length !== 0 && + options.map((option) => (jsxRuntimeExports.jsx(Option, { value: option.value, label: option.name, "data-test-id": `option-${option.name}` }, option.value)))] }), error && jsxRuntimeExports.jsx(Message$1, { validation: "error", children: error }), jsxRuntimeExports.jsx("input", { type: "hidden", name: name, value: selectedOption?.value })] })); +} + +/** + * The root group is identified by an empty string, to avoid possible clashes with a level with + * a "Root" name. + */ +const ROOT_GROUP_IDENTIFIER = "[]"; +function getGroupIdentifier(names) { + return `[${names.join("::")}]`; +} +function isGroupIdentifier(name) { + return name.startsWith("[") && name.endsWith("]"); +} +function getGroupAndOptionNames(input) { + const namesList = input.split("::"); + return [namesList.slice(0, -1), namesList.slice(-1)[0]]; +} +function buildSubGroupOptions(groupNames) { + const parentGroupNames = groupNames.slice(0, -1); + const parentGroupIdentifier = getGroupIdentifier(parentGroupNames); + const name = groupNames[groupNames.length - 1]; + return { + type: "SubGroup", + name, + backOption: { + type: "previous", + label: "Back", + value: parentGroupIdentifier, + }, + options: [], + }; +} +/** + * Maps a flat list of options to a nested structure + * + * For example, given the following options: + * [ + * { "name": "Bass::Fender::Precision", "value": "bass__fender__precision" }, + * { "name": "Bass::Fender::Jazz", "value": "bass__fender__jazz" } + * { "name": "Drums", "value": "drums" }, + * ] + * + * The following nested structure will be returned: + * { + * "[]": { + * "type": "RootGroup", + * "options": [ + * { "label": "Bass", "value": "[Bass]", type: "next" }, + * { "label": "Drums", "value": "drums" }, + * ] + * }, + * "[Bass]": { + * "type": "SubGroup", + * "name": "Bass", + * "backOption": { "type": "previous", "label": "Back", "value": "[]" }, + * "options": [ + * { "label": "Fender", "value": "[Bass::Fender]", type: "next" }, + * ] + * }, + * "[Bass::Fender]": { + * "type": "SubGroup", + * "name": "Fender", + * "backOption": { "type": "previous", "label": "Back", "value": "[Bass]" }, + * "options": [ + * { "menuLabel": "Precision", "label": "Bass > Fender > Precision", "value": "bass__fender__precision" }, + * { "menuLabel": "Jazz", "label": "Bass > Fender > Jazz", "value": "bass__fender__jazz" }, + * ] + * } + * } + * + * @param options original field options + * @param hasEmptyOption if true, adds an empty option to the root group + * @returns nested options + */ +function buildNestedOptions(options, hasEmptyOption) { + const result = { + [ROOT_GROUP_IDENTIFIER]: { + type: "RootGroup", + options: hasEmptyOption ? [{ label: "-", value: "" }] : [], + }, + }; + options.forEach((option) => { + const { name, value } = option; + if (!name.includes("::")) { + result[ROOT_GROUP_IDENTIFIER].options.push({ + value, + label: name, + }); + } + else { + const [groupNames, optionName] = getGroupAndOptionNames(name); + const groupIdentifier = getGroupIdentifier(groupNames); + if (!result[groupIdentifier]) { + result[groupIdentifier] = buildSubGroupOptions(groupNames); + } + result[groupIdentifier]?.options.push({ + value, + label: name.split("::").join(" > "), + menuLabel: optionName, + }); + // creates next options for each parent group, if they don't already exists + for (let i = 0; i < groupNames.length; i++) { + const parentGroupNames = groupNames.slice(0, i); + const nextGroupNames = groupNames.slice(0, i + 1); + const parentGroupIdentifier = getGroupIdentifier(parentGroupNames); + const nextGroupIdentifier = getGroupIdentifier(nextGroupNames); + if (!result[parentGroupIdentifier]) { + result[parentGroupIdentifier] = + buildSubGroupOptions(parentGroupNames); + } + if (result[parentGroupIdentifier]?.options.find((o) => o.value === nextGroupIdentifier) === undefined) { + result[parentGroupIdentifier]?.options.push({ + type: "next", + label: nextGroupNames[nextGroupNames.length - 1], + value: nextGroupIdentifier, + }); + } + } + } + }); + return result; +} +/** + * When one or more options are selected, the Combobox component renders the label + * for an option in the input, searching for an option passed as a child with the + * same value as the selected option. + * + * In the first render we are passing only the root group options as children, + * and if we already have some selected values from a SubGroup, the component is not + * able to find the label for the selected option. + * + * We therefore need to pass all the non-navigation options as children in the first render. + * The passed options are cached by the Combobox component, so we can safely remove them + * after the first render and pass only the root group options. + */ +function getInitialGroup(nestedOptions) { + const result = { + type: "RootGroup", + options: [], + }; + Object.values(nestedOptions).forEach(({ options }) => { + result.options.push(...options.filter(({ type }) => type === undefined)); + }); + return result; +} +function useNestedOptions({ options, hasEmptyOption, }) { + const nestedOptions = reactExports.useMemo(() => buildNestedOptions(options, hasEmptyOption), [options, hasEmptyOption]); + const [currentGroup, setCurrentGroup] = reactExports.useState(getInitialGroup(nestedOptions)); + reactExports.useEffect(() => { + setCurrentGroup(nestedOptions[ROOT_GROUP_IDENTIFIER]); + }, [nestedOptions]); + const setCurrentGroupByIdentifier = (identifier) => { + const group = nestedOptions[identifier]; + if (group) { + setCurrentGroup(group); + } + }; + return { + currentGroup, + isGroupIdentifier, + setCurrentGroupByIdentifier, + }; +} + +function MultiSelect({ field }) { + const { label, options, error, value, name, required, description } = field; + const { currentGroup, isGroupIdentifier, setCurrentGroupByIdentifier } = useNestedOptions({ + options, + hasEmptyOption: false, + }); + const [selectedValues, setSelectValues] = reactExports.useState(value || []); + const wrapperRef = reactExports.useRef(null); + reactExports.useEffect(() => { + if (wrapperRef.current && required) { + const combobox = wrapperRef.current.querySelector("[role=combobox]"); + combobox?.setAttribute("aria-required", "true"); + } + }, [wrapperRef, required]); + const handleChange = (changes) => { + if (Array.isArray(changes.selectionValue)) { + const lastSelectedItem = changes.selectionValue.slice(-1).toString(); + if (isGroupIdentifier(lastSelectedItem)) { + setCurrentGroupByIdentifier(lastSelectedItem); + } + else { + setSelectValues(changes.selectionValue); + } + } + }; + return (jsxRuntimeExports.jsxs(Field$1, { children: [selectedValues.map((selectedValue) => (jsxRuntimeExports.jsx("input", { type: "hidden", name: `${name}[]`, value: selectedValue }, selectedValue))), jsxRuntimeExports.jsxs(Label$1, { children: [label, required && jsxRuntimeExports.jsx(Span, { "aria-hidden": "true", children: "*" })] }), description && (jsxRuntimeExports.jsx(Hint$1, { dangerouslySetInnerHTML: { __html: description } })), jsxRuntimeExports.jsxs(Combobox, { ref: wrapperRef, isMultiselectable: true, inputProps: { required }, isEditable: false, validation: error ? "error" : undefined, onChange: handleChange, selectionValue: selectedValues, maxHeight: "auto", children: [currentGroup.type === "SubGroup" && (jsxRuntimeExports.jsx(Option, { ...currentGroup.backOption })), currentGroup.type === "SubGroup" ? (jsxRuntimeExports.jsx(OptGroup, { "aria-label": currentGroup.name, children: currentGroup.options.map((option) => (jsxRuntimeExports.jsx(Option, { ...option, children: option.menuLabel ?? option.label }, option.value))) })) : (currentGroup.options.map((option) => (jsxRuntimeExports.jsx(Option, { ...option }, option.value))))] }), error && jsxRuntimeExports.jsx(Message$1, { validation: "error", children: error })] })); +} + +function Tagger({ field, onChange }) { + const { label, options, error, value, name, required, description } = field; + const { currentGroup, isGroupIdentifier, setCurrentGroupByIdentifier } = useNestedOptions({ + options, + hasEmptyOption: true, + }); + const selectionValue = value ?? ""; + const [isExpanded, setIsExpanded] = reactExports.useState(false); + const wrapperRef = reactExports.useRef(null); + reactExports.useEffect(() => { + if (wrapperRef.current && required) { + const combobox = wrapperRef.current.querySelector("[role=combobox]"); + combobox?.setAttribute("aria-required", "true"); + } + }, [wrapperRef, required]); + const handleChange = (changes) => { + if (typeof changes.selectionValue === "string" && + isGroupIdentifier(changes.selectionValue)) { + setCurrentGroupByIdentifier(changes.selectionValue); + return; + } + if (typeof changes.selectionValue === "string") { + onChange(changes.selectionValue); + } + if (changes.isExpanded !== undefined) { + setIsExpanded(changes.isExpanded); + } + }; + return (jsxRuntimeExports.jsxs(Field$1, { children: [jsxRuntimeExports.jsxs(Label$1, { children: [label, required && jsxRuntimeExports.jsx(Span, { "aria-hidden": "true", children: "*" })] }), description && (jsxRuntimeExports.jsx(Hint$1, { dangerouslySetInnerHTML: { __html: description } })), jsxRuntimeExports.jsxs(Combobox, { ref: wrapperRef, inputProps: { required, name }, isEditable: false, validation: error ? "error" : undefined, onChange: handleChange, selectionValue: selectionValue, inputValue: selectionValue, renderValue: ({ selection }) => selection?.label ?? jsxRuntimeExports.jsx(EmptyValueOption, {}), isExpanded: isExpanded, children: [currentGroup.type === "SubGroup" && (jsxRuntimeExports.jsx(Option, { ...currentGroup.backOption })), currentGroup.type === "SubGroup" ? (jsxRuntimeExports.jsx(OptGroup, { "aria-label": currentGroup.name, children: currentGroup.options.map((option) => (jsxRuntimeExports.jsx(Option, { ...option, children: option.menuLabel ?? option.label }, option.value))) })) : (currentGroup.options.map((option) => option.value === "" ? (jsxRuntimeExports.jsx(Option, { ...option, children: jsxRuntimeExports.jsx(EmptyValueOption, {}) }, option.value)) : (jsxRuntimeExports.jsx(Option, { ...option }, option.value))))] }), error && jsxRuntimeExports.jsx(Message$1, { validation: "error", children: error })] })); +} + +function useWysiwyg({ hasWysiwyg, baseLocale, hasAtMentions, userRole, brandId, }) { + const isInitializedRef = reactExports.useRef(false); + const { addToast } = useToast(); + const { t } = useTranslation(); + return reactExports.useCallback(async (ref) => { + if (hasWysiwyg && ref && !isInitializedRef.current) { + isInitializedRef.current = true; + const { createEditor } = await import('wysiwyg').then(function (n) { return n.m; }); + const editor = await createEditor(ref, { + editorType: "supportRequests", + hasAtMentions, + userRole, + brandId, + baseLocale, + }); + const notifications = editor.plugins.get("Notification"); + // Handle generic notifications and errors with "toast" notifications + notifications.on("show", (event, data) => { + event.stop(); // Prevent the default notification from being shown via window.alert + const message = data.message instanceof Error + ? data.message.message + : data.message; + const { type, title } = data; + addToast(({ close }) => (jsxRuntimeExports.jsxs(Notification, { type: type, children: [jsxRuntimeExports.jsx(Title, { children: title }), message, jsxRuntimeExports.jsx(Close, { "aria-label": t("new-request-form.close-label", "Close"), onClick: close })] }))); + }); + } + }, [hasWysiwyg, baseLocale, hasAtMentions, userRole, brandId, addToast, t]); +} + +const StyledField = styled(Field) ` + .ck.ck-editor { + margin-top: ${(props) => props.theme.space.xs}; + } +`; +const StyledMessage = styled(Message) ` + .ck.ck-editor + & { + margin-top: ${(props) => props.theme.space.xs}; + } +`; +function TextArea({ field, hasWysiwyg, baseLocale, hasAtMentions, userRole, brandId, onChange, }) { + const { label, error, value, name, required, description } = field; + const ref = useWysiwyg({ + hasWysiwyg, + baseLocale, + hasAtMentions, + userRole, + brandId, + }); + return (jsxRuntimeExports.jsxs(StyledField, { children: [jsxRuntimeExports.jsxs(Label, { children: [label, required && jsxRuntimeExports.jsx(Span, { "aria-hidden": "true", children: "*" })] }), description && (jsxRuntimeExports.jsx(Hint, { dangerouslySetInnerHTML: { __html: description } })), jsxRuntimeExports.jsx(Textarea, { ref: ref, name: name, defaultValue: value, validation: error ? "error" : undefined, required: required, onChange: (e) => onChange(e.target.value), rows: 6, isResizable: true }), error && jsxRuntimeExports.jsx(StyledMessage, { validation: "error", children: error })] })); +} + +const isCustomField = (field) => { + return (field.name.startsWith("custom_field_") || + field.name.startsWith("request[custom_fields]")); +}; +const TicketFields = ({ fields, baseLocale, hasAtMentions, userRole, userId, defaultOrganizationId, organizationField, brandId, handleChange, }) => { + return (jsxRuntimeExports.jsx(jsxRuntimeExports.Fragment, { children: fields.map((field) => { + switch (field.type) { + case "text": + case "integer": + case "decimal": + case "regexp": + return (jsxRuntimeExports.jsx(Input, { field: field, onChange: (value) => handleChange(field, value) }, field.name)); + case "partialcreditcard": + return (jsxRuntimeExports.jsx(CreditCard, { field: field, onChange: (value) => handleChange(field, value) })); + case "textarea": + return (jsxRuntimeExports.jsx(TextArea, { field: field, hasWysiwyg: false, baseLocale: baseLocale, hasAtMentions: hasAtMentions, userRole: userRole, brandId: brandId, onChange: (value) => handleChange(field, value) }, field.name)); + case "checkbox": + return (jsxRuntimeExports.jsx(Checkbox, { field: field, onChange: (value) => handleChange(field, value) })); + case "date": + return (jsxRuntimeExports.jsx(DatePicker, { field: field, locale: baseLocale, valueFormat: "date", onChange: (value) => handleChange(field, value) })); + case "multiselect": + return jsxRuntimeExports.jsx(MultiSelect, { field: field }); + case "tagger": + return (jsxRuntimeExports.jsx(Tagger, { field: field, onChange: (value) => handleChange(field, value) }, field.name)); + case "lookup": + return (jsxRuntimeExports.jsx(LookupField, { field: field, userId: userId, organizationId: organizationField !== undefined + ? organizationField?.value + : defaultOrganizationId, onChange: (value) => handleChange(field, value) }, field.name)); + default: + return jsxRuntimeExports.jsx(jsxRuntimeExports.Fragment, {}); + } + }) })); +}; + +function DropDown({ field, onChange }) { + const { label, options, error, value, name, required, description } = field; + const selectionValue = value == null ? "" : value.toString(); + const wrapperRef = reactExports.useRef(null); + reactExports.useEffect(() => { + if (wrapperRef.current && required) { + const combobox = wrapperRef.current.querySelector("[role=combobox]"); + combobox?.setAttribute("aria-required", "true"); + } + }, [wrapperRef, required]); + return (jsxRuntimeExports.jsxs(Field$1, { children: [jsxRuntimeExports.jsxs(Label$1, { children: [label, required && jsxRuntimeExports.jsx(Span, { "aria-hidden": "true", children: "*" })] }), description && (jsxRuntimeExports.jsx(Hint$1, { dangerouslySetInnerHTML: { __html: description } })), jsxRuntimeExports.jsxs(Combobox, { ref: wrapperRef, inputProps: { name, required }, isEditable: false, validation: error ? "error" : undefined, inputValue: selectionValue, selectionValue: selectionValue, renderValue: ({ selection }) => selection?.label || jsxRuntimeExports.jsx(EmptyValueOption, {}), onChange: ({ selectionValue }) => { + if (selectionValue !== undefined) { + onChange(selectionValue); + } + }, children: [!required && (jsxRuntimeExports.jsx(Option, { value: "", label: "-", children: jsxRuntimeExports.jsx(EmptyValueOption, {}) })), options.map((option) => (jsxRuntimeExports.jsx(Option, { value: option.value.toString(), label: option.name }, option.value)))] }), error && jsxRuntimeExports.jsx(Message$1, { validation: "error", children: error })] })); +} + +export { DropDown as D, Input as I, TextArea as T, DatePicker as a, TicketFields as b, getCustomObjectKey as g, isCustomField as i }; diff --git a/assets/wysiwyg-bundle.js b/assets/wysiwyg-bundle.js index 8ab6d6946..f8992bae9 100644 --- a/assets/wysiwyg-bundle.js +++ b/assets/wysiwyg-bundle.js @@ -1,4 +1,4 @@ -import { g as getDefaultExportFromCjs, c as commonjsGlobal } from 'shared'; +import { n as getDefaultExportFromCjs, o as commonjsGlobal } from 'shared'; function _mergeNamespaces(n, m) { m.forEach(function (e) { diff --git a/rollup.config.mjs b/rollup.config.mjs index 5318291df..6d46cc5d0 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -36,7 +36,6 @@ export default defineConfig([ "new-request-form": "src/modules/new-request-form/index.tsx", "flash-notifications": "src/modules/flash-notifications/index.ts", "service-catalog": "src/modules/service-catalog/index.tsx", - "ticket-fields": "src/modules/ticket-fields/index.tsx", }, output: { dir: "assets", @@ -53,6 +52,10 @@ export default defineConfig([ return "shared"; } + if (id.includes("src/modules/ticket-fields")) { + return "ticket-fields"; + } + // Bundle all files from `src/modules/MODULE_NAME/translations/locales/*.json to `${MODULE_NAME}-translations.js` const translationFileMatch = id.match(TRANSLATION_FILE_REGEX); if (translationFileMatch) { diff --git a/templates/document_head.hbs b/templates/document_head.hbs index 060fe9644..92f035646 100644 --- a/templates/document_head.hbs +++ b/templates/document_head.hbs @@ -14,11 +14,10 @@ "new-request-form": "{{asset 'new-request-form-bundle.js'}}", "flash-notifications": "{{asset 'flash-notifications-bundle.js'}}", "service-catalog": "{{asset 'service-catalog-bundle.js'}}", - "ticket-fields": "{{asset 'ticket-fields-bundle.js'}}", "new-request-form-translations": "{{asset 'new-request-form-translations-bundle.js'}}", "shared": "{{asset 'shared-bundle.js'}}", - "wysiwyg": "{{asset 'wysiwyg-bundle.js'}}", - "index": "{{asset 'index-bundle.js'}}" + "ticket-fields": "{{asset 'ticket-fields-bundle.js'}}", + "wysiwyg": "{{asset 'wysiwyg-bundle.js'}}" } }