diff --git a/frontend/common/utils/__tests__/copyToClipboard.test.ts b/frontend/common/utils/__tests__/copyToClipboard.test.ts new file mode 100644 index 000000000000..f4d77c6ff72b --- /dev/null +++ b/frontend/common/utils/__tests__/copyToClipboard.test.ts @@ -0,0 +1,47 @@ +import { copyToClipboard } from 'common/utils/copyToClipboard' + +const writeText = jest.fn() +const toast = jest.fn() + +beforeEach(() => { + writeText.mockReset().mockResolvedValue(undefined) + toast.mockReset() + ;(global as any).toast = toast + Object.defineProperty(global, 'navigator', { + configurable: true, + value: { clipboard: { writeText } }, + writable: true, + }) +}) + +describe('copyToClipboard', () => { + it('writes the value and toasts the default success message', async () => { + await copyToClipboard('DEFAULT_VALUE') + + expect(writeText).toHaveBeenCalledWith('DEFAULT_VALUE') + expect(toast).toHaveBeenCalledWith('Copied to clipboard') + }) + + it('toasts a caller-supplied success message instead', async () => { + await copyToClipboard('prompt', 'Cleanup prompt copied to clipboard') + + expect(toast).toHaveBeenCalledWith('Cleanup prompt copied to clipboard') + }) + + it('toasts the failure and rethrows when the write is rejected', async () => { + const error = new Error('denied') + writeText.mockRejectedValue(error) + + await expect(copyToClipboard('value')).rejects.toThrow(error) + expect(toast).toHaveBeenCalledWith('Failed to copy to clipboard') + }) + + it('toasts a caller-supplied failure message instead', async () => { + writeText.mockRejectedValue(new Error('denied')) + + await expect( + copyToClipboard('value', undefined, 'Could not copy the value'), + ).rejects.toThrow() + expect(toast).toHaveBeenCalledWith('Could not copy the value') + }) +}) diff --git a/frontend/common/utils/copyToClipboard.ts b/frontend/common/utils/copyToClipboard.ts new file mode 100644 index 000000000000..7a9bf4f96591 --- /dev/null +++ b/frontend/common/utils/copyToClipboard.ts @@ -0,0 +1,21 @@ +/** + * Write `value` to the clipboard and toast the outcome. + * + * Rethrows after toasting so callers that need to react to a failure can, + * but the toast means most callers do not have to. + */ +export const copyToClipboard = async ( + value: string, + successMessage?: string, + errorMessage?: string, +) => { + try { + await navigator.clipboard.writeText(value) + toast(successMessage ?? 'Copied to clipboard') + } catch (error) { + toast(errorMessage ?? 'Failed to copy to clipboard') + throw error + } +} + +export default copyToClipboard diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index 2c545577bbdf..a7718160284e 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -71,6 +71,7 @@ export const planNames = { startup: 'Startup', } import BaseUtils from './base/_utils' +import { copyToClipboard } from './copyToClipboard' const Utils = Object.assign({}, BaseUtils, { appendImage: (src: string) => { const img = document.createElement('img') @@ -148,19 +149,7 @@ const Utils = Object.assign({}, BaseUtils, { return res }, - copyToClipboard: async ( - value: string, - successMessage?: string, - errorMessage?: string, - ) => { - try { - await navigator.clipboard.writeText(value) - toast(successMessage ?? 'Copied to clipboard') - } catch (error) { - toast(errorMessage ?? 'Failed to copy to clipboard') - throw error - } - }, + copyToClipboard, displayLimitAlert(type: string, percentage: number | undefined) { const envOrProject = diff --git a/frontend/documentation/components/ValueEditor.stories.tsx b/frontend/documentation/components/ValueEditor.stories.tsx index f58af691f279..96cde20ba51a 100644 --- a/frontend/documentation/components/ValueEditor.stories.tsx +++ b/frontend/documentation/components/ValueEditor.stories.tsx @@ -1,9 +1,9 @@ -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import type { Meta, StoryObj } from 'storybook' -import ValueEditor from 'components/ValueEditor' -import FieldLabel from 'components/base/forms/FieldLabel' import Constants from 'common/constants' +import ValueEditor from 'components/ValueEditor' +import ControlWeightChip from 'components/mv/ControlWeightChip' const meta: Meta = { parameters: { chromatic: { disableSnapshot: false } }, @@ -13,23 +13,21 @@ export default meta type Story = StoryObj -const DEFAULT_TOOLTIP = Constants.strings.REMOTE_CONFIG_DESCRIPTION - const Interactive = ({ initialValue = '', - label, - tooltip = DEFAULT_TOOLTIP, + width = 640, ...props }: Record) => { const [value, setValue] = useState(initialValue) return ( -
- {label && {label}} +
) } +// Empty state. The "Enter a value..." text is not a real ::placeholder — it is +// rendered into the contenteditable and styled by `code.txt.empty`. export const Default: Story = { render: () => , } @@ -59,6 +57,26 @@ export const Json: Story = { ), } +// A value that arrives after mount, the way a loaded feature does. Detection +// has to wait for it: a mount-only check left JSON rendering as .txt. +const LateLoading = () => { + const [value, setValue] = useState('') + useEffect(() => { + const timer = setTimeout(() => setValue('{ "colour": "blue" }'), 150) + return () => clearTimeout(timer) + }, []) + return ( +
+ +
+ ) +} + +export const ValueArrivesAfterMount: Story = { + render: () => , +} + +// Invalid JSON surfaces a warning against the active language label. export const InvalidJson: Story = { render: () => ( @@ -69,7 +87,7 @@ export const CodeMedium: Story = { render: () => ( @@ -82,13 +100,31 @@ export const Disabled: Story = { ), } -export const OnlyOneLang: Story = { +// The multivariate control value carries a weight chip and a tooltip, so it is +// the widest label this component gets. Label and format buttons share one flex +// row, so they compress rather than overlap. +const controlWeight = + +export const BadgeLabel: Story = { + render: () => ( + + ), +} + +// The same label at the narrowest width the drawer reaches. +export const BadgeLabelNarrow: Story = { render: () => ( '} + label='Control Value' + labelAfter={controlWeight} + labelTooltip={Constants.strings.REMOTE_CONFIG_DESCRIPTION_VARIATION} + initialValue='DEFAULT_VALUE' + width={380} /> ), } diff --git a/frontend/web/components/Highlight.js b/frontend/web/components/Highlight.js index d8370a1a9219..8c0dfa23739e 100644 --- a/frontend/web/components/Highlight.js +++ b/frontend/web/components/Highlight.js @@ -156,6 +156,11 @@ class Highlight extends React.Component { { this.setState({ changed: true }) - setValue( - Utils.getTypedValue( - Utils.safeParseEventValue(controlValue), - ), - ) + setValue(Utils.getTypedValue(controlValue)) }} canCopyValue={ permission && @@ -278,8 +275,8 @@ const SegmentOverrideInner = class Override extends React.Component { {showValue ? ( <>
- { + : (newValue) => { this.setState({ changed: true }) - setValue( - Utils.getTypedValue(Utils.safeParseEventValue(e)), - ) + setValue(Utils.getTypedValue(newValue)) } } placeholder="Value e.g. 'big' " @@ -300,8 +295,9 @@ const SegmentOverrideInner = class Override extends React.Component { ) : (
- } value={v.value} data-test={`segment-override-value-${index}`} placeholder="Value e.g. 'big' " @@ -309,11 +305,9 @@ const SegmentOverrideInner = class Override extends React.Component { onChange={ readOnly ? null - : (e) => { + : (newValue) => { this.setState({ changed: true }) - setValue( - Utils.getTypedValue(Utils.safeParseEventValue(e)), - ) + setValue(Utils.getTypedValue(newValue)) } } /> diff --git a/frontend/web/components/ValueEditor.js b/frontend/web/components/ValueEditor.js deleted file mode 100644 index 038eb1656d31..000000000000 --- a/frontend/web/components/ValueEditor.js +++ /dev/null @@ -1,251 +0,0 @@ -import React, { Component } from 'react' -import cx from 'classnames' -import Highlight from './Highlight' -import { Clipboard } from 'polyfill-react-native' -import Icon from './icons/Icon' -import BareButton from './base/forms/BareButton' - -import toml from 'toml' -import yaml from 'yaml' - -function xmlIsInvalid(xmlStr) { - const parser = new DOMParser() - const dom = parser.parseFromString(xmlStr, 'application/xml') - for (const element of Array.from(dom.querySelectorAll('parsererror'))) { - if (element instanceof HTMLElement) { - // Found the error. - return element.innerText - } - } - // No errors found. - return false -} - -class Validation extends Component { - constructor(props) { - super(props) - this.state = {} - this.validateLanguage(this.props.language, this.props.value) - } - - componentDidUpdate(prevProps) { - if ( - prevProps.value !== this.props.value || - prevProps.language !== this.props.language - ) { - this.validateLanguage(this.props.language, this.props.value) - } - } - - validateLanguage = (language, value) => { - const validate = new Promise((resolve) => { - switch (language) { - case 'json': { - try { - JSON.parse(value) - resolve(false) - } catch (e) { - resolve(e.message) - } - break - } - case 'ini': { - try { - toml.parse(value) - resolve(false) - } catch (e) { - resolve(e.message) - } - break - } - case 'yaml': { - try { - yaml.parse(value) - resolve(false) - } catch (e) { - resolve(e.message) - } - break - } - case 'xml': { - try { - const error = xmlIsInvalid(value) - resolve(error) - } catch (e) { - resolve('Failed to parse XML') - } - break - } - default: { - resolve(false) - break - } - } - }) - - validate.then((error) => { - this.setState({ error }) - }) - } - - render() { - const displayLanguage = - this.props.language === 'ini' ? 'toml' : this.props.language - return this.state.error ? ( - - - - } - > - {`${displayLanguage} validation error, please check your value.
Error: ${this.state.error}`} -
- ) : ( - - - - ) - } -} -class ValueEditor extends Component { - state = { - language: 'txt', - } - - componentDidMount() { - if (this.props.language) { - this.setState({ language: this.props.language }) - this.renderValidation(this.props.language) - } - if (!this.props.value) return - try { - const v = JSON.parse(this.props.value) - if (typeof v !== 'object') return - this.setState({ language: 'json' }) - } catch (e) {} - } - - renderValidation = () => ( - - ) - - copyValue = () => { - const res = Clipboard.setString(this.props.value) - toast( - res ? 'Clipboard set' : 'Could not set clipboard :(', - res ? '' : 'danger', - ) - } - - render() { - const { ...rest } = this.props - const showCopy = !this.props.onlyOneLang && !this.props.disabled - return ( -
- {!this.props.onlyOneLang && ( - - { - e.preventDefault() - e.stopPropagation() - this.setState({ language: 'txt' }) - }} - className={cx('txt', { active: this.state.language === 'txt' })} - > - .txt - - { - e.preventDefault() - e.stopPropagation() - this.setState({ language: 'json' }) - }} - className={cx('json', { active: this.state.language === 'json' })} - > - .json {this.state.language === 'json' && this.renderValidation()} - - { - e.preventDefault() - e.stopPropagation() - this.setState({ language: 'xml' }) - }} - className={cx('xml', { active: this.state.language === 'xml' })} - > - .xml {this.state.language === 'xml' && this.renderValidation()} - - { - e.preventDefault() - e.stopPropagation() - - this.setState({ language: 'ini' }) - }} - className={cx('ini', { active: this.state.language === 'ini' })} - > - .toml {this.state.language === 'ini' && this.renderValidation()} - - { - e.preventDefault() - e.stopPropagation() - this.setState({ language: 'yaml' }) - }} - className={cx('yaml', { active: this.state.language === 'yaml' })} - > - .yaml {this.state.language === 'yaml' && this.renderValidation()} - - - )} - - {showCopy && ( - - - - )} - - {E2E ? ( -