Refine and improve policy and query editing interface (#4004)

This commit is contained in:
Luke Heath 2022-02-04 15:30:27 -06:00 committed by GitHub
parent 2084b7d310
commit 14d36d8e4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 464 additions and 218 deletions

View File

@ -0,0 +1 @@
* Refine and improve query and policy editing interface

View File

@ -0,0 +1,49 @@
import React, { KeyboardEvent } from "react";
import { Meta, Story } from "@storybook/react";
import { noop } from "lodash";
// @ts-ignore
import AutoSizeInputField from ".";
import "../../../../index.scss";
interface IAutoSizeInputFieldProps {
name: string;
placeholder: string;
value: string;
inputClassName?: string;
hasError?: boolean;
isDisabled?: boolean;
isFocused?: boolean;
onFocus: () => void;
onBlur: () => void;
onChange: (newSelectedValue: string) => void;
onKeyPress: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
}
export default {
component: AutoSizeInputField,
title: "Components/FormFields/Input",
args: {
autofocus: false,
disabled: false,
isFocused: false,
error: "",
inputClassName: "",
inputWrapperClass: "",
inputOptions: "",
name: "",
placeholder: "Type here...",
type: "",
value: "",
onFocus: noop,
onChange: noop,
onKeyPress: noop,
},
} as Meta;
const Template: Story<IAutoSizeInputFieldProps> = (props) => (
<AutoSizeInputField {...props} />
);
export const Default = Template.bind({});

View File

@ -0,0 +1,102 @@
import React, {
ChangeEvent,
KeyboardEvent,
useEffect,
useRef,
useState,
} from "react";
import classnames from "classnames";
interface IAutoSizeInputFieldProps {
name: string;
placeholder: string;
value: string;
inputClassName?: string;
hasError?: boolean;
isDisabled?: boolean;
isFocused?: boolean;
onFocus: () => void;
onBlur: () => void;
onChange: (newSelectedValue: string) => void;
onKeyPress: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
}
const baseClass = "component__auto-size-input-field";
const TeamsDropdown = ({
name,
placeholder,
value,
inputClassName,
hasError,
isDisabled,
onFocus,
onBlur,
onChange,
onKeyPress,
isFocused,
}: IAutoSizeInputFieldProps): JSX.Element => {
const [inputValue, setInputValue] = useState(value);
const inputClasses = classnames(baseClass, inputClassName, "no-hover", {
[`${baseClass}--disabled`]: isDisabled,
[`${baseClass}--error`]: hasError,
[`${baseClass}__textarea`]: true,
});
const inputElement = useRef<any>(null);
useEffect(() => {
onChange(inputValue);
}, [inputValue]);
useEffect(() => {
if (isFocused && inputElement.current) {
inputElement.current.focus();
inputElement.current.selectionStart = inputValue.length;
inputElement.current.selectionEnd = inputValue.length;
}
}, [isFocused]);
const onInputChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
setInputValue(event.currentTarget.value);
};
const onInputFocus = () => {
isFocused = true;
onFocus();
};
const onInputBlur = () => {
isFocused = false;
onBlur();
};
const onInputKeyPress = (event: KeyboardEvent<HTMLTextAreaElement>) => {
onKeyPress(event);
};
return (
<div className={baseClass}>
<label className="input-sizer" data-value={inputValue} htmlFor={name}>
<textarea
name={name}
id={name}
onChange={onInputChange}
placeholder={placeholder}
value={inputValue}
className={inputClasses}
cols={12}
rows={1}
tabIndex={0}
onFocus={onInputFocus}
onBlur={onInputBlur}
onKeyPress={onInputKeyPress}
ref={inputElement}
/>
</label>
</div>
);
};
export default TeamsDropdown;

View File

@ -0,0 +1,42 @@
.component__auto-size-input-field {
box-sizing: border-box;
color: $core-fleet-black;
&::placeholder {
color: $ui-fleet-black-50;
}
&:focus {
outline: none;
border-color: $core-vibrant-blue;
}
.input-sizer {
display: inline-grid;
vertical-align: top;
align-items: center;
position: relative;
&::after,
input,
textarea {
width: auto;
grid-area: 1 / 2;
resize: none;
background: none;
appearance: none;
border: none;
}
textarea {
height: 100%;
overflow: hidden;
}
&::after {
content: attr(data-value) " ";
visibility: hidden;
white-space: pre-wrap;
}
}
}

View File

@ -0,0 +1 @@
export { default } from "./AutoSizeInputField";

View File

@ -1,9 +1,10 @@
/* eslint-disable jsx-a11y/no-noninteractive-element-to-interactive-role */ /* eslint-disable jsx-a11y/no-noninteractive-element-to-interactive-role */
/* eslint-disable jsx-a11y/interactive-supports-focus */ /* eslint-disable jsx-a11y/interactive-supports-focus */
import React, { useState, useContext } from "react"; import React, { useState, useContext, KeyboardEvent } from "react";
import { IAceEditor } from "react-ace/lib/types"; import { IAceEditor } from "react-ace/lib/types";
import ReactTooltip from "react-tooltip"; import ReactTooltip from "react-tooltip";
import { isUndefined } from "lodash"; import { isUndefined } from "lodash";
import classnames from "classnames";
import { addGravatarUrlToResource } from "fleet/helpers"; import { addGravatarUrlToResource } from "fleet/helpers";
// @ts-ignore // @ts-ignore
@ -18,6 +19,7 @@ import FleetAce from "components/FleetAce";
import Button from "components/buttons/Button"; import Button from "components/buttons/Button";
import Checkbox from "components/forms/fields/Checkbox"; import Checkbox from "components/forms/fields/Checkbox";
import Spinner from "components/Spinner"; import Spinner from "components/Spinner";
import AutoSizeInputField from "components/forms/fields/AutoSizeInputField";
// @ts-ignore // @ts-ignore
import InputField from "components/forms/fields/InputField"; import InputField from "components/forms/fields/InputField";
import NewPolicyModal from "../NewPolicyModal"; import NewPolicyModal from "../NewPolicyModal";
@ -52,7 +54,7 @@ const PolicyForm = ({
onOpenSchemaSidebar, onOpenSchemaSidebar,
renderLiveQueryWarning, renderLiveQueryWarning,
}: IPolicyFormProps): JSX.Element => { }: IPolicyFormProps): JSX.Element => {
const [errors, setErrors] = useState<{ [key: string]: string }>({}); const [errors, setErrors] = useState<{ [key: string]: any }>({});
const [isNewPolicyModalOpen, setIsNewPolicyModalOpen] = useState<boolean>( const [isNewPolicyModalOpen, setIsNewPolicyModalOpen] = useState<boolean>(
false false
); );
@ -87,7 +89,6 @@ const PolicyForm = ({
} = useContext(PolicyContext); } = useContext(PolicyContext);
const { const {
currentTeam,
currentUser, currentUser,
isTeamObserver, isTeamObserver,
isGlobalObserver, isGlobalObserver,
@ -150,6 +151,16 @@ const PolicyForm = ({
setLastEditedQueryBody(sqlString); setLastEditedQueryBody(sqlString);
}; };
const onInputKeypress = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key.toLowerCase() === "enter" && !event.shiftKey) {
event.preventDefault();
event.currentTarget.blur();
setIsEditingName(false);
setIsEditingDescription(false);
setIsEditingResolution(false);
}
};
const promptSavePolicy = (forceNew = false) => ( const promptSavePolicy = (forceNew = false) => (
evt: React.MouseEvent<HTMLButtonElement> evt: React.MouseEvent<HTMLButtonElement>
) => { ) => {
@ -230,41 +241,44 @@ const PolicyForm = ({
); );
}; };
const policyNameClasses = classnames("policy-name-wrapper", {
[`${baseClass}--editing`]: isEditingName,
});
const policyDescriptionClasses = classnames("policy-description-wrapper", {
[`${baseClass}--editing`]: isEditingDescription,
});
const policyResolutionClasses = classnames("policy-resolution-wrapper", {
[`${baseClass}--editing`]: isEditingResolution,
});
const renderName = () => { const renderName = () => {
if (isEditMode) { if (isEditMode) {
if (isEditingName) {
return (
<InputField
id="policy-name"
type="textarea"
name="policy-name"
error={errors.name}
value={lastEditedQueryName}
placeholder="Add name here"
inputClassName={`${baseClass}__policy-name`}
onChange={setLastEditedQueryName}
inputOptions={{
autoFocus: true,
onFocus: (e: React.FocusEvent<HTMLInputElement>) => {
// sets cursor to end of inputfield
const val = e.target.value;
e.target.value = "";
e.target.value = val;
},
}}
/>
);
}
return ( return (
<h1 <>
role="button" <div className={policyNameClasses}>
className={`${baseClass}__policy-name`} <AutoSizeInputField
onClick={() => setIsEditingName(true)} name="policy-name"
> placeholder="Add name here"
{lastEditedQueryName} value={lastEditedQueryName}
<img alt="Edit name" src={PencilIcon} /> hasError={errors && errors.name}
</h1> inputClassName={`${baseClass}__policy-name`}
onChange={setLastEditedQueryName}
onFocus={() => setIsEditingName(true)}
onBlur={() => setIsEditingName(false)}
onKeyPress={onInputKeypress}
isFocused={isEditingName}
/>
<a className="edit-link" onClick={() => setIsEditingName(true)}>
<img
className={`edit-icon ${isEditingName && "hide"}`}
alt="Edit name"
src={PencilIcon}
/>
</a>
</div>
</>
); );
} }
@ -273,38 +287,32 @@ const PolicyForm = ({
const renderDescription = () => { const renderDescription = () => {
if (isEditMode) { if (isEditMode) {
if (isEditingDescription) {
return (
<InputField
id="policy-description"
type="textarea"
name="policy-description"
value={lastEditedQueryDescription}
placeholder="Add description here."
inputClassName={`${baseClass}__policy-description`}
onChange={setLastEditedQueryDescription}
inputOptions={{
autoFocus: true,
onFocus: (e: React.FocusEvent<HTMLInputElement>) => {
// sets cursor to end of inputfield
const val = e.target.value;
e.target.value = "";
e.target.value = val;
},
}}
/>
);
}
return ( return (
<span <>
role="button" <div className={policyDescriptionClasses}>
className={`${baseClass}__policy-description`} <AutoSizeInputField
onClick={() => setIsEditingDescription(true)} name="policy-description"
> placeholder="Add description here."
{lastEditedQueryDescription || "Add description here."} value={lastEditedQueryDescription}
<img alt="Edit description" src={PencilIcon} /> inputClassName={`${baseClass}__policy-description`}
</span> onChange={setLastEditedQueryDescription}
onFocus={() => setIsEditingDescription(true)}
onBlur={() => setIsEditingDescription(false)}
onKeyPress={onInputKeypress}
isFocused={isEditingDescription}
/>
<a
className="edit-link"
onClick={() => setIsEditingDescription(true)}
>
<img
className={`edit-icon ${isEditingDescription && "hide"}`}
alt="Edit name"
src={PencilIcon}
/>
</a>
</div>
</>
); );
} }
@ -313,52 +321,33 @@ const PolicyForm = ({
const renderResolution = () => { const renderResolution = () => {
if (isEditMode) { if (isEditMode) {
if (isEditingResolution) {
return (
<div className={`${baseClass}__policy-resolve`}>
{" "}
<b>Resolve:</b> <br />
<InputField
id="policy-resolution"
type="textarea"
name="policy-resolution"
value={lastEditedQueryResolution}
placeholder="Add resolution here."
inputClassName={`${baseClass}__policy-resolution`}
onChange={setLastEditedQueryResolution}
inputOptions={{
autoFocus: true,
onFocus: (e: React.FocusEvent<HTMLInputElement>) => {
// sets cursor to end of inputfield
const val = e.target.value;
e.target.value = "";
e.target.value = val;
},
}}
/>
</div>
);
}
return ( return (
<> <>
<div className="resolve-text-wrapper"> <p className="resolve-title">
<b>Resolve:</b>{" "} <strong>Resolve:</strong>
<span </p>
role="button" <div className={policyResolutionClasses}>
className={`${baseClass}__policy-resolution`} <AutoSizeInputField
name="policy-resolution"
placeholder="Add resolution here."
value={lastEditedQueryResolution}
inputClassName={`${baseClass}__policy-resolution`}
onChange={setLastEditedQueryResolution}
onFocus={() => setIsEditingResolution(true)}
onBlur={() => setIsEditingResolution(false)}
onKeyPress={onInputKeypress}
isFocused={isEditingResolution}
/>
<a
className="edit-link"
onClick={() => setIsEditingResolution(true)} onClick={() => setIsEditingResolution(true)}
> >
<img alt="Edit resolution" src={PencilIcon} /> <img
</span> className={`edit-icon ${isEditingResolution && "hide"}`}
<br /> alt="Edit name"
<span src={PencilIcon}
role="button" />
className={`${baseClass}__policy-resolution`} </a>
onClick={() => setIsEditingResolution(true)}
>
{lastEditedQueryResolution || "Add resolution here."}
</span>
</div> </div>
</> </>
); );

View File

@ -39,27 +39,63 @@
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */
} }
.edit-link {
cursor: pointer;
}
.name-description-resolve { .name-description-resolve {
flex-grow: 1; flex-grow: 1;
margin-right: 24px; margin: $pad-medium 24px 0 0;
.policy-form__policy-name { .policy-name-wrapper,
margin: $pad-large 0; .policy-description-wrapper,
line-height: 2rem; .policy-resolution-wrapper {
height: 2rem; display: flex;
&:not(.policy-form--editing) {
textarea:hover {
cursor: pointer;
color: $core-vibrant-blue;
}
}
}
.policy-name-wrapper {
.edit-icon {
position: relative;
left: 3px;
top: 13px;
}
.policy-form__policy-name,
.input-sizer::after {
font-size: $large;
}
.component__auto-size-input-field {
letter-spacing: -0.5px;
line-height: 2.3rem;
padding-top: 2px;
}
}
.policy-description-wrapper {
padding-top: $pad-small;
.edit-icon {
position: relative;
left: 3px;
top: 0;
}
}
.edit-icon {
width: 14px;
height: 14px;
opacity: 1;
transition: opacity 0.2s;
&.hide {
opacity: 0;
}
} }
.policy-form__policy-resolve, .policy-form__policy-resolve,
.resolve-text-wrapper { .resolve-text-wrapper {
margin-top: $pad-large; margin-top: $pad-large;
} }
#policy-description { .resolve-title {
height: 38px; margin-bottom: 0;
}
textarea.policy-form__policy-description {
margin-top: 0.8125rem;
}
img {
height: 14px;
padding-left: $pad-small;
} }
} }
@ -115,7 +151,6 @@
} }
&__policy-name { &__policy-name {
margin-top: $pad-large;
font-size: $large; font-size: $large;
&.input-field--error { &.input-field--error {

View File

@ -1,8 +1,9 @@
import React, { useState, useContext, useEffect } from "react"; import React, { useState, useContext, useEffect, KeyboardEvent } from "react";
import { IAceEditor } from "react-ace/lib/types"; import { IAceEditor } from "react-ace/lib/types";
import ReactTooltip from "react-tooltip"; import ReactTooltip from "react-tooltip";
import { size } from "lodash"; import { size } from "lodash";
import { useDebouncedCallback } from "use-debounce/lib"; import { useDebouncedCallback } from "use-debounce/lib";
import classnames from "classnames";
import { addGravatarUrlToResource } from "fleet/helpers"; import { addGravatarUrlToResource } from "fleet/helpers";
// @ts-ignore // @ts-ignore
@ -13,12 +14,14 @@ import { QueryContext } from "context/query";
import { IQuery, IQueryFormData } from "interfaces/query"; import { IQuery, IQueryFormData } from "interfaces/query";
import Avatar from "components/Avatar"; import Avatar from "components/Avatar";
import FleetAce from "components/FleetAce"; // @ts-ignore import FleetAce from "components/FleetAce";
// @ts-ignore
import validateQuery from "components/forms/validators/validate_query"; import validateQuery from "components/forms/validators/validate_query";
import Button from "components/buttons/Button"; import Button from "components/buttons/Button";
import Checkbox from "components/forms/fields/Checkbox"; import Checkbox from "components/forms/fields/Checkbox";
import Spinner from "components/Spinner"; // @ts-ignore import Spinner from "components/Spinner";
import InputField from "components/forms/fields/InputField"; // @ts-ignore
import AutoSizeInputField from "components/forms/fields/AutoSizeInputField";
import NewQueryModal from "../NewQueryModal"; import NewQueryModal from "../NewQueryModal";
import PlatformCompatibility from "../PlatformCompatibility"; import PlatformCompatibility from "../PlatformCompatibility";
import InfoIcon from "../../../../../../assets/images/icon-info-purple-14x14@2x.png"; import InfoIcon from "../../../../../../assets/images/icon-info-purple-14x14@2x.png";
@ -154,6 +157,15 @@ const QueryForm = ({
setLastEditedQueryBody(sqlString); setLastEditedQueryBody(sqlString);
}; };
const onInputKeypress = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key.toLowerCase() === "enter" && !event.shiftKey) {
event.preventDefault();
event.currentTarget.blur();
setIsEditingName(false);
setIsEditingDescription(false);
}
};
const promptSaveQuery = (forceNew = false) => ( const promptSaveQuery = (forceNew = false) => (
evt: React.MouseEvent<HTMLButtonElement> evt: React.MouseEvent<HTMLButtonElement>
) => { ) => {
@ -233,48 +245,41 @@ const QueryForm = ({
return <PlatformCompatibility compatiblePlatforms={compatiblePlatforms} />; return <PlatformCompatibility compatiblePlatforms={compatiblePlatforms} />;
}; };
const queryNameClasses = classnames("query-name-wrapper", {
[`${baseClass}--editing`]: isEditingName,
});
const queryDescriptionClasses = classnames("query-description-wrapper", {
[`${baseClass}--editing`]: isEditingDescription,
});
const renderName = () => { const renderName = () => {
if (isEditMode) { if (isEditMode) {
if (isEditingName) {
return (
<InputField
id="query-name"
type="textarea"
name="query-name"
error={errors.name}
value={lastEditedQueryName}
placeholder="Add name here"
inputClassName={`${baseClass}__query-name`}
onChange={setLastEditedQueryName}
inputOptions={{
autoFocus: true,
onFocus: (e: React.FocusEvent<HTMLInputElement>) => {
// sets cursor to end of inputfield
const val = e.target.value;
e.target.value = "";
e.target.value = val;
},
}}
/>
);
}
/* eslint-disable */
// eslint complains about the button role
// applied to H1 - this is needed to avoid
// using a real button
// prettier-ignore
return ( return (
<h1 <>
role="button" <div className={queryNameClasses}>
className={`${baseClass}__query-name`} <AutoSizeInputField
onClick={() => setIsEditingName(true)} name="query-name"
> placeholder="Add name here"
{lastEditedQueryName} value={lastEditedQueryName}
<img alt="Edit name" src={PencilIcon} /> hasError={errors && errors.name}
</h1> inputClassName={`${baseClass}__query-name`}
onChange={setLastEditedQueryName}
onFocus={() => setIsEditingName(true)}
onBlur={() => setIsEditingName(false)}
onKeyPress={onInputKeypress}
isFocused={isEditingName}
/>
<a className="edit-link" onClick={() => setIsEditingName(true)}>
<img
className={`edit-icon ${isEditingName && "hide"}`}
alt="Edit name"
src={PencilIcon}
/>
</a>
</div>
</>
); );
/* eslint-enable */
} }
return <h1 className={`${baseClass}__query-name no-hover`}>New query</h1>; return <h1 className={`${baseClass}__query-name no-hover`}>New query</h1>;
@ -282,47 +287,34 @@ const QueryForm = ({
const renderDescription = () => { const renderDescription = () => {
if (isEditMode) { if (isEditMode) {
if (isEditingDescription) {
return (
<InputField
id="query-description"
type="textarea"
name="query-description"
value={lastEditedQueryDescription}
placeholder="Add description here."
inputClassName={`${baseClass}__query-description`}
onChange={setLastEditedQueryDescription}
inputOptions={{
autoFocus: true,
onFocus: (e: React.FocusEvent<HTMLInputElement>) => {
// sets cursor to end of inputfield
const val = e.target.value;
e.target.value = "";
e.target.value = val;
},
}}
/>
);
}
/* eslint-disable */
// eslint complains about the button role
// applied to span - this is needed to avoid
// using a real button
// prettier-ignore
return ( return (
<span <>
role="button" <div className={queryDescriptionClasses}>
className={`${baseClass}__query-description`} <AutoSizeInputField
onClick={() => setIsEditingDescription(true)} name="query-description"
> placeholder="Add description here."
{lastEditedQueryDescription} value={lastEditedQueryDescription}
<img alt="Edit description" src={PencilIcon} /> inputClassName={`${baseClass}__query-description`}
</span> onChange={setLastEditedQueryDescription}
onFocus={() => setIsEditingDescription(true)}
onBlur={() => setIsEditingDescription(false)}
onKeyPress={onInputKeypress}
isFocused={isEditingDescription}
/>
<a
className="edit-link"
onClick={() => setIsEditingDescription(true)}
>
<img
className={`edit-icon ${isEditingDescription && "hide"}`}
alt="Edit name"
src={PencilIcon}
/>
</a>
</div>
</>
); );
/* eslint-enable */
} }
return null; return null;
}; };

View File

@ -39,24 +39,59 @@
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */
} }
.edit-link {
cursor: pointer;
}
.name-description { .name-description {
flex-grow: 1; flex-grow: 1;
margin-right: 24px; margin: $pad-medium 24px 0 0;
.query-form__query-name { .query-name-wrapper {
margin-top: $pad-large; display: flex;
line-height: 2rem; &:not(.query-form--editing) {
height: 2rem; textarea:hover {
cursor: pointer;
color: $core-vibrant-blue;
}
}
.edit-icon {
position: relative;
left: 3px;
top: 13px;
}
.query-form__query-name,
.input-sizer::after {
font-size: $large;
}
.component__auto-size-input-field {
letter-spacing: -0.5px;
line-height: 2.3rem;
padding-top: 2px;
}
} }
.query-form__query-description:not(textarea) { .query-description-wrapper {
margin: 0.25rem 0 1rem; display: flex;
padding-top: $pad-small;
&:not(.query-form--editing) {
textarea:hover {
cursor: pointer;
color: $core-vibrant-blue;
}
}
.edit-icon {
position: relative;
left: 3px;
top: 0;
}
} }
textarea.query-form__query-description { .edit-icon {
margin-top: 0.8125rem; width: 14px;
} height: 14px;
img { opacity: 1;
transform: scale(0.5); transition: opacity 0.2s;
position: relative; &.hide {
top: 6px; opacity: 0;
}
} }
} }
@ -103,7 +138,7 @@
} }
&__query-name { &__query-name {
margin-top: $pad-large; margin-top: 0;
font-size: $large; font-size: $large;
&.input-field--error { &.input-field--error {