diff --git a/README.md b/README.md index 06eeb3b7ba..0038020f7d 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ and [C#](https://docs.tilt.dev/example_csharp.html). **Optimizing a Tiltfile?** Search for the function you need in our [complete API reference](https://docs.tilt.dev/api.html). +**Custom UI Actions?** `v1alpha1.ui_button()` now always shows its inputs in a modal dialog for clearer input collection (replaces the old dropdown). + ## Community & Contributions **Questions:** Join [the Kubernetes slack](http://slack.k8s.io) and diff --git a/web/src/ApiButton.stories.tsx b/web/src/ApiButton.stories.tsx index aa03817bb6..c6d8e262a2 100644 --- a/web/src/ApiButton.stories.tsx +++ b/web/src/ApiButton.stories.tsx @@ -4,7 +4,11 @@ import styled from "styled-components" import { ApiButton } from "./ApiButton" import { OverviewButtonMixin } from "./OverviewButton" import { TiltSnackbarProvider } from "./Snackbar" -import { oneUIButton, textFieldForUIButton } from "./testdata" +import { + oneUIButton, + textFieldForUIButton, + boolFieldForUIButton, +} from "./testdata" import { UIInputSpec } from "./types" export default { @@ -53,3 +57,41 @@ export const TextInputOptions = () => { }) return } + +export const ButtonWithModal = () => { + const button = oneUIButton({ + buttonText: "Deploy with Modal", + inputSpecs: [ + textFieldForUIButton("environment", "dev", "dev, staging, prod"), + textFieldForUIButton("replicas", "1", "1-10"), + ], + }) + return +} + +export const ModalWithManyInputs = () => { + const button = oneUIButton({ + buttonText: "Deploy Complex App", + inputSpecs: [ + textFieldForUIButton( + "environment", + "dev", + "Environment (dev/staging/prod)" + ), + textFieldForUIButton("replicas", "3", "Number of replicas"), + textFieldForUIButton("version", "latest", "Image version"), + textFieldForUIButton("namespace", "default", "Kubernetes namespace"), + boolFieldForUIButton("enable_debug", false), + ], + }) + return +} + +export const ModalWithConfirmation = () => { + const button = oneUIButton({ + buttonText: "Delete Resources", + requiresConfirmation: true, + inputSpecs: [textFieldForUIButton("reason", "", "Reason for deletion")], + }) + return +} diff --git a/web/src/ApiButton.test.tsx b/web/src/ApiButton.test.tsx index e640759042..4208be3496 100644 --- a/web/src/ApiButton.test.tsx +++ b/web/src/ApiButton.test.tsx @@ -135,29 +135,29 @@ describe("ApiButton", () => { customRender().rerender }) - it("renders an options button", () => { + it("renders the button with inputs", () => { expect( - screen.getByLabelText(`Open ${uibutton.spec!.text!} options`) + screen.getByLabelText(`Trigger ${uibutton.spec!.text!}`) ).toBeInTheDocument() }) - it("shows the options form with inputs when the options button is clicked", () => { - const optionButton = screen.getByLabelText( - `Open ${uibutton.spec!.text!} options` + it("shows the modal with inputs when the button is clicked", () => { + const button = screen.getByLabelText( + `Trigger ${uibutton.spec!.text!}` ) - userEvent.click(optionButton) + userEvent.click(button) expect( - screen.getByText(`Options for ${uibutton.spec!.text!}`) + screen.getByText(`Configure ${uibutton.spec!.text!}`) ).toBeInTheDocument() }) it("only shows inputs for visible inputs", () => { - // Open the options dialog first - const optionButton = screen.getByLabelText( - `Open ${uibutton.spec!.text!} options` + // Open the modal by clicking the button + const button = screen.getByLabelText( + `Trigger ${uibutton.spec!.text!}` ) - userEvent.click(optionButton) + userEvent.click(button) inputSpecs.forEach((spec) => { if (!spec.hidden) { @@ -167,11 +167,11 @@ describe("ApiButton", () => { }) it("allows an empty text string when there's a default value", async () => { - // Open the options dialog first - const optionButton = screen.getByLabelText( - `Open ${uibutton.spec!.text!} options` + // Open the modal by clicking the button + const button = screen.getByLabelText( + `Trigger ${uibutton.spec!.text!}` ) - userEvent.click(optionButton) + userEvent.click(button) // Get the input element with the hardcoded default text const inputWithDefault = screen.getByDisplayValue("default text") @@ -182,11 +182,11 @@ describe("ApiButton", () => { }) it("submits the current options when the submit button is clicked", async () => { - // Open the options dialog first - const optionButton = screen.getByLabelText( - `Open ${uibutton.spec!.text!} options` + // Open the modal by clicking the button + const button = screen.getByLabelText( + `Trigger ${uibutton.spec!.text!}` ) - userEvent.click(optionButton) + userEvent.click(button) // Make a couple changes to the inputs userEvent.type(screen.getByLabelText("text_field"), "new_value") @@ -196,8 +196,8 @@ describe("ApiButton", () => { userEvent.click(screen.getByText("choice1")) userEvent.click(screen.getByText("choice3")) - // Click the submit button - userEvent.click(screen.getByLabelText(`Trigger ${uibutton.spec!.text!}`)) + // Click the confirm button in modal + userEvent.click(screen.getByText("Confirm & Execute")) // Wait for the button to be enabled again, // which signals successful trigger button response @@ -259,16 +259,16 @@ describe("ApiButton", () => { }) it("submits default options when the submit button is clicked", async () => { - // The testing setup already includes a field with default text, - // so we can go ahead and click the submit button + // Open the modal userEvent.click(screen.getByLabelText(`Trigger ${uibutton.spec!.text!}`)) + + // Click confirm in modal + userEvent.click(screen.getByText("Confirm & Execute")) - // Wait for the button to be enabled again, - // which signals successful trigger button response + // Wait for the modal to close and API call to complete await waitFor( () => - expect(screen.getByLabelText(`Trigger ${uibutton.spec!.text!}`)).not - .toBeDisabled + expect(screen.queryByText("Confirm & Execute")).not.toBeInTheDocument() ) const calls = fetchMock.calls() @@ -341,19 +341,19 @@ describe("ApiButton", () => { }) it("are read from local storage", () => { - // Open the options dialog + // Open the modal userEvent.click( - screen.getByLabelText(`Open ${uibutton.spec!.text!} options`) + screen.getByLabelText(`Trigger ${uibutton.spec!.text!}`) ) expect(screen.getByLabelText("text1")).toHaveValue("text value") expect(screen.getByLabelText("bool1")).toBeChecked() }) - it("are written to local storage when edited", () => { - // Open the options dialog + it("are written to local storage when modal is confirmed", () => { + // Open the modal userEvent.click( - screen.getByLabelText(`Open ${uibutton.spec!.text!} options`) + screen.getByLabelText(`Trigger ${uibutton.spec!.text!}`) ) // Type a new value in the text field @@ -363,8 +363,11 @@ describe("ApiButton", () => { // Uncheck the boolean field userEvent.click(screen.getByLabelText("bool1")) + + // Confirm the modal to persist values + userEvent.click(screen.getByText("Confirm & Execute")) - // Expect local storage values are updated + // Expect local storage values are updated after confirmation expect(buttonInputsAccessor.get()).toEqual({ text1: "new value!", bool1: false, diff --git a/web/src/ApiButton.tsx b/web/src/ApiButton.tsx index b99777a6e5..f6d91a6c8b 100644 --- a/web/src/ApiButton.tsx +++ b/web/src/ApiButton.tsx @@ -1,15 +1,21 @@ import { + Button, ButtonClassKey, ButtonGroup, ButtonProps, + Dialog, + DialogActions, + DialogContent, + DialogTitle, FormControlLabel, Icon, + IconButton, InputLabel, MenuItem, Select, SvgIcon, } from "@material-ui/core" -import ArrowDropDownIcon from "@material-ui/icons/ArrowDropDown" +import { Close as CloseIcon } from "@material-ui/icons" import { ClassNameMap } from "@material-ui/styles" import moment from "moment" import { useSnackbar } from "notistack" @@ -26,7 +32,6 @@ import styled from "styled-components" import { annotations } from "./annotations" import { ReactComponent as CloseSvg } from "./assets/svg/close.svg" import { usePersistentState } from "./BrowserStorage" -import FloatDialog from "./FloatDialog" import { useHudErrorContext } from "./HudErrorContext" import { InstrumentedButton, @@ -222,10 +227,63 @@ const ApiButtonInputCheckbox = styled(InstrumentedCheckbox)` } ` -export const ApiButtonInputsToggleButton = styled(InstrumentedButton)` - &&&& { - margin-left: unset; /* Override any margins passed down through "className" props */ - padding: 0 0; +// Styled components for the input modal dialog +const StyledDialog = styled(Dialog)` + .MuiDialog-paper { + min-width: 480px; + max-width: 600px; + } +` + +const StyledDialogTitle = styled(DialogTitle)` + display: flex; + justify-content: space-between; + align-items: center; + font-family: ${Font.monospace}; + font-size: ${FontSize.default}; + background-color: ${Color.grayLightest}; + border-bottom: 1px solid ${Color.gray50}; + + h2 { + margin: 0; + font-weight: normal; + color: ${Color.gray10}; + } +` + +const StyledDialogContent = styled(DialogContent)` + padding: 24px; + background-color: white; +` + +const StyledDialogActions = styled(DialogActions)` + padding: 16px 24px; + background-color: ${Color.grayLightest}; + border-top: 1px solid ${Color.gray50}; + gap: 12px; +` + +const CancelButton = styled(Button)` + color: ${Color.gray40}; + border: 1px solid ${Color.gray50}; + + &:hover { + background-color: ${Color.grayLightest}; + border-color: ${Color.gray40}; + } +` + +const ConfirmButton = styled(Button)` + background-color: ${Color.green}; + color: white; + + &:hover { + background-color: ${Color.greenLight}; + } + + &:disabled { + background-color: ${Color.gray50}; + color: ${Color.gray40}; } ` @@ -342,59 +400,98 @@ export function ApiButtonForm(props: ApiButtonFormProps) { ) } -type ApiButtonWithOptionsProps = { - submit: JSX.Element +interface ApiButtonInputModalProps { + open: boolean + onClose: () => void + onConfirm: (values: { [name: string]: any }) => void uiButton: UIButton - setInputValue: (name: string, value: any) => void - getInputValue: (name: string) => any | undefined - className?: string - text: string + initialValues: { [name: string]: any } } -function ApiButtonWithOptions(props: ApiButtonWithOptionsProps & ButtonProps) { - const [open, setOpen] = useState(false) - const anchorRef = useRef(null) +function ApiButtonInputModal(props: ApiButtonInputModalProps) { + const [inputValues, setInputValues] = useState(props.initialValues) - const { - submit, - uiButton, - setInputValue, - getInputValue, - text, - ...buttonProps - } = props + const setInputValue = (name: string, value: any) => { + setInputValues({ ...inputValues, [name]: value }) + } + + const getInputValue = (name: string) => inputValues[name] + + const handleConfirm = () => { + props.onConfirm(inputValues) + props.onClose() + } + + const buttonText = props.uiButton.spec?.text || "Button" + const visibleInputs = + props.uiButton.spec?.inputs?.filter((input) => !input.hidden) || [] return ( - <> - - {props.submit} - + + Configure {buttonText} + { - setOpen((prevOpen) => !prevOpen) - }} - aria-label={`Open ${text} options`} > - - - - { - setOpen(false) - }} - anchorEl={anchorRef.current} - title={`Options for ${text}`} - > - - - + + + + + + {visibleInputs.length > 0 ? ( + <> +

+ Review and modify the input values, then confirm to execute the + action. +

+ + + ) : ( +

+ Are you sure you want to execute "{buttonText}"? +

+ )} +
+ + + + Cancel + + + Confirm & Execute + + + ) } @@ -598,6 +695,7 @@ export function ApiButton(props: PropsWithChildren) { const [loading, setLoading] = useState(false) const [confirming, setConfirming] = useState(false) + const [showInputModal, setShowInputModal] = useState(false) // Reset the confirmation state when the button's name changes useLayoutEffect(() => setConfirming(false), [buttonName]) @@ -606,10 +704,19 @@ export function ApiButton(props: PropsWithChildren) { const disabled = loading || uiButton.spec?.disabled || false const buttonText = uiButton.spec?.text || "Button" + // Visible inputs (non-hidden) determine if we show a configuration modal before action + const hasVisibleInputs = uiButton.spec?.inputs?.some((input) => !input.hidden) + const onClick = async (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() + // If there are visible inputs, always show modal to configure them before executing + if (hasVisibleInputs) { + setShowInputModal(true) + return + } + if (uiButton.spec?.requiresConfirmation && !confirming) { setConfirming(true) return @@ -670,49 +777,78 @@ export function ApiButton(props: PropsWithChildren) { ) - // show the options button if there are any non-hidden inputs - const visibleInputs = uiButton.spec?.inputs?.filter((i) => !i.hidden) || [] - if (visibleInputs.length) { + if (hasVisibleInputs) { const setInputValue = (name: string, value: any) => { - // Copy to a new object so that the reference changes to force a rerender. setInputValues({ ...inputValues, [name]: value }) } const getInputValue = (name: string) => inputValues[name] return ( - + <> + + {submitButton} + {/* Confirmation cancel button only shown if in confirming state and no inputs (handled in else) */} + + setShowInputModal(false)} + onConfirm={async (values) => { + setInputValues(values) // Persist values for next time + try { + setLoading(true) + await updateButtonStatus(uiButton, values) + } catch (error) { + setError(`Error updating button: ${error}`) + } finally { + setLoading(false) + } + }} + uiButton={uiButton} + initialValues={inputValues} + /> + ) } else { return ( - - {submitButton} - + setConfirming(false)} - {...buttonProps} + > + {submitButton} + setConfirming(false)} + {...buttonProps} + /> + + {/* Modal for confirmation only (no inputs) */} + setShowInputModal(false)} + onConfirm={async () => { + try { + setLoading(true) + await updateButtonStatus(uiButton, inputValues) + } catch (error) { + setError(`Error updating button: ${error}`) + } finally { + setLoading(false) + } + }} + uiButton={uiButton} + initialValues={inputValues} /> - + ) } }