Improve loading states (#6471)

This commit is contained in:
Luke Heath 2022-07-05 12:10:53 -07:00 committed by GitHub
parent 5cd845a15e
commit 1c6c379f4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 244 additions and 203 deletions

View File

@ -29,6 +29,6 @@ In todays episode of the Future of Device Management podcast, we speak with [
<meta name="category" value="podcasts">
<meta name="authorGitHubUsername" value="zwass">
<meta name="authorFullName" value="Zach Wasserman">
<meta name="publishedOn" value="2022-06-30">
<meta name="publishedOn" value="2099-06-30">
<meta name="articleTitle" value="Future of device management episode 2">
<meta name="articleImageUrl" value="../website/assets/images/articles/future-of-device-management-ep2-cover-1600x900@2x.jpg">

View File

@ -0,0 +1 @@
* Improve UI loading states.

View File

@ -26,7 +26,7 @@ describe("Policies flow (empty)", () => {
.type(
"{selectall}SELECT 1 FROM users WHERE username = 'backup' LIMIT 1;"
);
cy.findByRole("button", { name: /save policy/i }).click();
cy.getAttached(".policy-form__save").click();
cy.getAttached(".policy-form__policy-save-modal-name")
.click()
.type("Does the device have a user named 'backup'?");
@ -36,7 +36,7 @@ describe("Policies flow (empty)", () => {
cy.getAttached(".policy-form__policy-save-modal-resolution")
.click()
.type("Create a user named 'backup'");
cy.findByRole("button", { name: /^Save$/ }).click();
cy.getAttached(".policy-form__button--modal-save").click();
cy.findByText(/policy created/i).should("exist");
});
@ -45,9 +45,9 @@ describe("Policies flow (empty)", () => {
cy.findByText(/add a policy/i).click();
});
cy.findByText(/gatekeeper enabled/i).click();
cy.findByRole("button", { name: /save policy/i }).click();
cy.getAttached(".policy-form__save").click();
cy.getAttached(".policy-form__button-wrap--modal").within(() => {
cy.findAllByRole("button", { name: /^Save$/ }).click();
cy.getAttached(".policy-form__button--modal-save").click();
});
cy.findByText(/policy created/i).should("exist");
});
@ -188,7 +188,7 @@ describe("Policies flow (empty)", () => {
testCompatibility(el, i, [true, false, false]);
});
});
cy.findByRole("button", { name: /save policy/i }).click(); // open save policy modal
cy.getAttached(".policy-form__save").click();
cy.getAttached(".platform-selector").within(() => {
cy.getAttached(".fleet-checkbox__input").each((el, i) => {
@ -204,7 +204,7 @@ describe("Policies flow (empty)", () => {
cy.getAttached(".add-policy-modal__modal").within(() => {
cy.findByText("Automatic login disabled (macOS)").click();
});
cy.findByRole("button", { name: /save policy/i }).click(); // open save policy modal
cy.getAttached(".policy-form__save").click();
cy.getAttached(".platform-selector").within(() => {
cy.getAttached(".fleet-checkbox__input").each((el, i) => {
@ -215,7 +215,7 @@ describe("Policies flow (empty)", () => {
testSelections(el, i, [false, false, false]);
});
});
cy.findByRole("button", { name: /^Save$/ }).should("be.disabled");
cy.getAttached(".policy-form__button--modal-save").should("be.disabled");
});
it("allows user to overide preselected platforms when saving new policy", () => {
@ -231,7 +231,7 @@ describe("Policies flow (empty)", () => {
testCompatibility(el, i, [true, false, false]);
});
});
cy.findByRole("button", { name: /save policy/i }).click(); // open save policy modal
cy.getAttached(".policy-form__save").click();
cy.getAttached(".platform-selector").within(() => {
cy.getAttached(".fleet-checkbox__input").each((el, i) => {
@ -243,7 +243,7 @@ describe("Policies flow (empty)", () => {
testSelections(el, i, [false, false, true]);
});
});
cy.findByRole("button", { name: /^Save$/ }).click();
cy.getAttached(".policy-form__button--modal-save").click();
cy.findByText(/policy created/i).should("exist");
// confirm that new policy was saved with user-selected platforms
@ -268,8 +268,8 @@ describe("Policies flow (empty)", () => {
cy.getAttached(".add-policy-modal__modal").within(() => {
cy.findByText("Antivirus healthy (macOS)").click();
});
cy.findByRole("button", { name: /save policy/i }).click();
cy.findByRole("button", { name: /^Save$/ }).click();
cy.getAttached(".policy-form__save").click();
cy.getAttached(".policy-form__button--modal-save").click();
cy.findByText(/policy created/i).should("exist");
// edit platform selections for policy

View File

@ -554,9 +554,9 @@ describe("Premium tier - Global Admin user", () => {
cy.findByText(/gatekeeper enabled/i).click();
cy.getAttached(".policy-form__button-wrap").within(() => {
cy.findByRole("button", { name: /run/i }).should("exist");
cy.findByRole("button", { name: /save policy/i }).click();
cy.getAttached(".policy-form__save").click();
});
cy.findByRole("button", { name: /^Save$/ }).click();
cy.getAttached(".policy-form__button--modal-save").click();
cy.findByText(/policy created/i).should("exist");
cy.findByText(/gatekeeper enabled/i).should("exist");
});

View File

@ -241,9 +241,9 @@ describe("Premium tier - Maintainer user", () => {
cy.findByText(/gatekeeper enabled/i).click();
cy.getAttached(".policy-form__button-wrap").within(() => {
cy.findByRole("button", { name: /run/i }).should("exist");
cy.findByRole("button", { name: /save policy/i }).click();
cy.getAttached(".policy-form__save").click();
});
cy.findByRole("button", { name: /^Save$/ }).click();
cy.getAttached(".policy-form__button--modal-save").click();
cy.findByText(/policy created/i).should("exist");
});
it("allows global maintainer to delete a team policy", () => {

View File

@ -301,9 +301,9 @@ describe("Premium tier - Team Admin user", () => {
cy.findByText(/gatekeeper enabled/i).click();
cy.getAttached(".policy-form__button-wrap").within(() => {
cy.findByRole("button", { name: /run/i }).should("exist");
cy.findByRole("button", { name: /save policy/i }).click();
cy.getAttached(".policy-form__save").click();
});
cy.findByRole("button", { name: /^Save$/ }).click();
cy.getAttached(".policy-form__button--modal-save").click();
cy.findByText(/policy created/i).should("exist");
});
it("allows team admin to edit a team policy", () => {

View File

@ -289,10 +289,8 @@ describe("Premium tier - Team observer/maintainer user", () => {
// Add a default policy
cy.findByText(/gatekeeper enabled/i).click();
cy.getAttached(".policy-form__button-wrap").within(() => {
cy.findByRole("button", { name: /save policy/i }).click();
});
cy.findByRole("button", { name: /^Save$/ }).click();
cy.getAttached(".policy-form__save").click();
cy.getAttached(".policy-form__button--modal-save").click();
cy.findByText(/policy created/i).should("exist");
// On maintaining team, should see "save" and "run" for a new policy

View File

@ -73,7 +73,6 @@ const DEBOUNCE_DELAY = 500;
const STALE_TIME = 60000;
const isLabel = (entity: ISelectTargetsEntity) => "label_type" in entity;
const isHost = (entity: ISelectTargetsEntity) => "hostname" in entity;
const parseLabels = (list?: ILabelSummary[]) => {
const allHosts = list?.filter((l) => l.name === "All Hosts") || [];
@ -324,13 +323,18 @@ const SelectTargets = ({
const renderTargetsCount = (): JSX.Element | null => {
if (isFetchingCounts) {
return <i style={{ color: "#8b8fa2" }}>Checking for online hosts...</i>;
return (
<>
<Spinner small />
<i style={{ color: "#8b8fa2" }}>Counting hosts</i>
</>
);
}
if (errorCounts) {
return (
<b style={{ color: "#d66c7b", margin: 0 }}>
There was a problem checking online hosts. Please try again later.
There was a problem counting hosts. Please try again later.
</b>
);
}

View File

@ -2,7 +2,6 @@ import React from "react";
import { Meta, Story } from "@storybook/react";
import Spinner from ".";
import { ISpinnerProps } from "./Spinner";
import "../../index.scss";
@ -14,6 +13,6 @@ export default {
},
} as Meta;
const Template: Story<ISpinnerProps> = (props) => <Spinner {...props} />;
const Template: Story = (props) => <Spinner {...props} />;
export const Default = Template.bind({});

View File

@ -1,25 +1,14 @@
import React from "react";
export interface ISpinnerProps {
isInButton?: boolean;
interface ISpinnerProps {
small?: boolean;
}
const baseClass = "loading-spinner";
const Spinner = ({ isInButton }: ISpinnerProps): JSX.Element => {
if (isInButton) {
return (
<div className="ring ring-for-button">
<div />
<div />
<div />
<div />
</div>
);
}
const Spinner = ({ small }: ISpinnerProps): JSX.Element => {
return (
<div className={baseClass}>
<div className={`${baseClass} ${small ? "small" : ""}`}>
<div className={`${baseClass}__ring`}>
<div />
<div />

View File

@ -25,29 +25,13 @@
width: 32px;
height: 32px;
margin: -4px;
border: 4px solid $core-white;
border: 4px solid;
border-radius: 50%;
animation: ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: $ui-vibrant-blue-25 transparent transparent transparent;
}
}
&__ring-for-button {
margin-right: $pad-small;
width: 16px;
height: 16px;
border-width: 3px;
border-color: rgba(255,255,255,0.25);
div {
margin: -3px;
width: 16px;
height: 16px;
border-width: 3px;
border-color: $core-white transparent transparent transparent;
}
}
div:nth-child(1) {
animation-delay: -0.45s;
}
@ -61,6 +45,38 @@
}
}
.loading-spinner.small {
background: none;
box-shadow: none;
height: auto;
margin: 0;
transform: scale(0.7);
}
button {
position: relative;
.loading-spinner {
background: none;
box-shadow: none;
margin: 0;
transform: scale(0.7);
position: absolute;
&__ring {
border-color: $core-white;
div {
border-color: $core-vibrant-blue transparent transparent transparent;
width: 32px;
height: 32px;
}
}
}
}
.spinner-wrap {
display: flex;
}
@keyframes ring {
0% {
transform: rotate(0deg);

View File

@ -157,6 +157,11 @@
margin-left: 16px;
font-size: $x-small;
display: flex;
align-items: center;
.loading-spinner {
margin-left: -12px;
}
span {
font-weight: 700;

View File

@ -102,84 +102,80 @@ const NewPolicyModal = ({
return (
<Modal title={"Save policy"} onExit={() => setIsNewPolicyModalOpen(false)}>
<>
{policyIsLoading ? (
<Spinner />
) : (
<form
onSubmit={handleSavePolicy}
className={`${baseClass}__save-modal-form`}
autoComplete="off"
<form
onSubmit={handleSavePolicy}
className={`${baseClass}__save-modal-form`}
autoComplete="off"
>
<InputField
name="name"
onChange={(value: string) => setName(value)}
value={name}
error={errors.name}
inputClassName={`${baseClass}__policy-save-modal-name`}
label="Name"
placeholder="What yes or no question does your policy ask about your devices?"
/>
<InputField
name="description"
onChange={(value: string) => setDescription(value)}
value={description}
inputClassName={`${baseClass}__policy-save-modal-description`}
label="Description"
placeholder="Add a description here (optional)"
/>
<InputField
name="resolution"
onChange={(value: string) => setResolution(value)}
value={resolution}
inputClassName={`${baseClass}__policy-save-modal-resolution`}
label="Resolution"
type="textarea"
placeholder="What steps should a device owner take to resolve a host that fails this policy? (optional)"
/>
{platformSelector.render()}
<div
className={`${baseClass}__button-wrap ${baseClass}__button-wrap--modal`}
>
<InputField
name="name"
onChange={(value: string) => setName(value)}
value={name}
error={errors.name}
inputClassName={`${baseClass}__policy-save-modal-name`}
label="Name"
placeholder="What yes or no question does your policy ask about your devices?"
/>
<InputField
name="description"
onChange={(value: string) => setDescription(value)}
value={description}
inputClassName={`${baseClass}__policy-save-modal-description`}
label="Description"
placeholder="Add a description here (optional)"
/>
<InputField
name="resolution"
onChange={(value: string) => setResolution(value)}
value={resolution}
inputClassName={`${baseClass}__policy-save-modal-resolution`}
label="Resolution"
type="textarea"
placeholder="What steps should a device owner take to resolve a host that fails this policy? (optional)"
/>
{platformSelector.render()}
<div
className={`${baseClass}__button-wrap ${baseClass}__button-wrap--modal`}
<Button
className={`${baseClass}__button--modal-cancel`}
onClick={() => setIsNewPolicyModalOpen(false)}
variant="text-link"
>
Cancel
</Button>
<span
className={`${baseClass}__button-wrap--modal-save`}
data-tip
data-for={`${baseClass}__button--modal-save-tooltip`}
data-tip-disable={!disableSave}
>
<Button
className={`${baseClass}__button--modal-cancel`}
onClick={() => setIsNewPolicyModalOpen(false)}
variant="text-link"
className={`${baseClass}__button--modal-save`}
type="submit"
variant="brand"
onClick={handleSavePolicy}
disabled={disableSave}
>
Cancel
{policyIsLoading ? <Spinner /> : "Save policy"}
</Button>
<span
className={`${baseClass}__button-wrap--modal-save`}
data-tip
data-for={`${baseClass}__button--modal-save-tooltip`}
data-tip-disable={!disableSave}
<ReactTooltip
className={`${baseClass}__button--modal-save-tooltip`}
place="bottom"
type="dark"
effect="solid"
id={`${baseClass}__button--modal-save-tooltip`}
backgroundColor="#3e4771"
>
<Button
className={`${baseClass}__button--modal-save`}
type="submit"
variant="brand"
onClick={handleSavePolicy}
disabled={disableSave}
>
Save
</Button>
<ReactTooltip
className={`${baseClass}__button--modal-save-tooltip`}
place="bottom"
type="dark"
effect="solid"
id={`${baseClass}__button--modal-save-tooltip`}
backgroundColor="#3e4771"
>
Select the platform(s) this
<br />
policy will be checked on
<br />
to save the policy.
</ReactTooltip>
</span>
</div>
</form>
)}
Select the platform(s) this
<br />
policy will be checked on
<br />
to save the policy.
</ReactTooltip>
</span>
</div>
</form>
</>
</Modal>
);

View File

@ -37,6 +37,7 @@ interface IPolicyFormProps {
storedPolicy: IPolicy | undefined;
isStoredPolicyLoading: boolean;
isCreatingNewPolicy: boolean;
isUpdatingPolicy: boolean;
onCreatePolicy: (formData: IPolicyFormData) => void;
onOsqueryTableSelect: (tableName: string) => void;
goToSelectTargets: () => void;
@ -64,6 +65,7 @@ const PolicyForm = ({
storedPolicy,
isStoredPolicyLoading,
isCreatingNewPolicy,
isUpdatingPolicy,
onCreatePolicy,
onOsqueryTableSelect,
goToSelectTargets,
@ -84,6 +86,7 @@ const PolicyForm = ({
const [isEditingResolution, setIsEditingResolution] = useState<boolean>(
false
);
const [isPolicySaving, setIsPolicySaving] = useState<boolean>(false);
// Note: The PolicyContext values should always be used for any mutable policy data such as query name
// The storedPolicy prop should only be used to access immutable metadata such as author id
@ -486,7 +489,7 @@ const PolicyForm = ({
onClick={promptSavePolicy()}
disabled={isEditMode && !isAnyPlatformSelected}
>
<>{!isEditMode ? "Save policy" : "Save"}</>
<>{isUpdatingPolicy ? <Spinner /> : "Save"}</>
</Button>
)}
<Button

View File

@ -228,6 +228,19 @@
}
}
&__button-wrap--modal-save {
display: flex;
}
&__button--modal-save {
width: 106px;
}
&__save {
display: flex;
width: 64px;
}
&__platform-error {
font-size: $small;
font-weight: $bold;

View File

@ -239,10 +239,7 @@ const QueryResults = ({
onClick={onStopQuery}
variant="alert"
>
<>
<Spinner isInButton />
Stop
</>
<>Stop</>
</Button>
</div>
);

View File

@ -70,6 +70,7 @@ const QueryEditor = ({
const [isCreatingNewPolicy, setIsCreatingNewPolicy] = useState<boolean>(
false
);
const [isUpdatingPolicy, setIsUpdatingPolicy] = useState<boolean>(false);
const [backendValidators, setBackendValidators] = useState<{
[key: string]: string;
}>({});
@ -108,6 +109,8 @@ const QueryEditor = ({
return false;
}
setIsUpdatingPolicy(true);
const updatedPolicy = deepDifference(formData, {
lastEditedQueryName,
lastEditedQueryDescription,
@ -141,6 +144,8 @@ const QueryEditor = ({
"Something went wrong updating your policy. Please try again."
);
}
} finally {
setIsUpdatingPolicy(false);
}
return false;
@ -174,6 +179,7 @@ const QueryEditor = ({
onOpenSchemaSidebar={onOpenSchemaSidebar}
renderLiveQueryWarning={renderLiveQueryWarning}
backendValidators={backendValidators}
isUpdatingPolicy={isUpdatingPolicy}
/>
</div>
);

View File

@ -160,6 +160,11 @@
margin-left: 16px;
font-size: $x-small;
display: flex;
align-items: center;
.loading-spinner {
margin-left: -12px;
}
span {
font-weight: 700;

View File

@ -78,65 +78,61 @@ const NewQueryModal = ({
return (
<Modal title={"Save query"} onExit={() => setIsSaveModalOpen(false)}>
<>
{isLoading ? (
<Spinner />
) : (
<form
onSubmit={handleUpdate}
className={`${baseClass}__save-modal-form`}
autoComplete="off"
<form
onSubmit={handleUpdate}
className={`${baseClass}__save-modal-form`}
autoComplete="off"
>
<InputField
name="name"
onChange={(value: string) => setName(value)}
value={name}
error={errors.name}
inputClassName={`${baseClass}__query-save-modal-name`}
label="Name"
placeholder="What is your query called?"
/>
<InputField
name="description"
onChange={(value: string) => setDescription(value)}
value={description}
inputClassName={`${baseClass}__query-save-modal-description`}
label="Description"
type="textarea"
placeholder="What information does your query reveal? (optional)"
/>
<Checkbox
name="observerCanRun"
onChange={setObserverCanRun}
value={observerCanRun}
wrapperClassName={`${baseClass}__query-save-modal-observer-can-run-wrapper`}
>
<InputField
name="name"
onChange={(value: string) => setName(value)}
value={name}
error={errors.name}
inputClassName={`${baseClass}__query-save-modal-name`}
label="Name"
placeholder="What is your query called?"
/>
<InputField
name="description"
onChange={(value: string) => setDescription(value)}
value={description}
inputClassName={`${baseClass}__query-save-modal-description`}
label="Description"
type="textarea"
placeholder="What information does your query reveal? (optional)"
/>
<Checkbox
name="observerCanRun"
onChange={setObserverCanRun}
value={observerCanRun}
wrapperClassName={`${baseClass}__query-save-modal-observer-can-run-wrapper`}
Observers can run
</Checkbox>
<p>
Users with the Observer role will be able to run this query on hosts
where they have access.
</p>
<hr />
<div
className={`${baseClass}__button-wrap ${baseClass}__button-wrap--modal`}
>
<Button
className={`${baseClass}__btn`}
onClick={() => setIsSaveModalOpen(false)}
variant="text-link"
>
Observers can run
</Checkbox>
<p>
Users with the Observer role will be able to run this query on
hosts where they have access.
</p>
<hr />
<div
className={`${baseClass}__button-wrap ${baseClass}__button-wrap--modal`}
Cancel
</Button>
<Button
className={`${baseClass}__btn ${baseClass}__save-modal__btn`}
type="submit"
variant="brand"
>
<Button
className={`${baseClass}__btn`}
onClick={() => setIsSaveModalOpen(false)}
variant="text-link"
>
Cancel
</Button>
<Button
className={`${baseClass}__btn`}
type="submit"
variant="brand"
>
Save query
</Button>
</div>
</form>
)}
{isLoading ? <Spinner /> : "Save query"}
</Button>
</div>
</form>
</>
</Modal>
);

View File

@ -38,6 +38,7 @@ interface IQueryFormProps {
storedQuery: IQuery | undefined;
isStoredQueryLoading: boolean;
isQuerySaving: boolean;
isQueryUpdating: boolean;
onCreateQuery: (formData: IQueryFormData) => void;
onOsqueryTableSelect: (tableName: string) => void;
goToSelectTargets: () => void;
@ -66,6 +67,7 @@ const QueryForm = ({
storedQuery,
isStoredQueryLoading,
isQuerySaving,
isQueryUpdating,
onCreateQuery,
onOsqueryTableSelect,
goToSelectTargets,
@ -440,11 +442,6 @@ const QueryForm = ({
const renderForGlobalAdminOrAnyMaintainer = (
<>
<form className={`${baseClass}__wrapper`} autoComplete="off">
{isSaveAsNewLoading && (
<div className={`${baseClass}__loading-overlay`}>
<Spinner />
</div>
)}
<div className={`${baseClass}__title-bar`}>
<div className="name-description">
{renderName()}
@ -496,7 +493,7 @@ const QueryForm = ({
onClick={promptSaveAsNewQuery()}
disabled={false}
>
Save as new
{isSaveAsNewLoading ? <Spinner /> : "Save as new"}
</Button>
)}
<div className="query-form__button-wrap--save-query-button">
@ -519,7 +516,7 @@ const QueryForm = ({
!hasTeamMaintainerPermissions
}
>
Save
{isQueryUpdating ? <Spinner /> : "Save"}
</Button>
</div>{" "}
<ReactTooltip

View File

@ -197,6 +197,19 @@
margin-right: $pad-xsmall;
}
&__save {
display: flex;
width: 64px;
}
&__save-as-new {
width: 82px;
}
&__save-modal__btn {
width: 106px;
}
&__title {
color: $core-fleet-black;
display: inline-block;

View File

@ -241,10 +241,7 @@ const QueryResults = ({
onClick={onStopQuery}
variant="alert"
>
<>
<Spinner isInButton />
Stop
</>
<>Stop</>
</Button>
</div>
);

View File

@ -62,6 +62,7 @@ const QueryEditor = ({
} = useContext(QueryContext);
const [isQuerySaving, setIsQuerySaving] = useState<boolean>(false);
const [isQueryUpdating, setIsQueryUpdating] = useState<boolean>(false);
useEffect(() => {
if (storedQueryError) {
@ -103,6 +104,8 @@ const QueryEditor = ({
return false;
}
setIsQueryUpdating(true);
const updatedQuery = deepDifference(formData, {
lastEditedQueryName,
lastEditedQueryDescription,
@ -125,6 +128,8 @@ const QueryEditor = ({
}
}
setIsQueryUpdating(false);
return false;
};
@ -152,6 +157,7 @@ const QueryEditor = ({
renderLiveQueryWarning={renderLiveQueryWarning}
backendValidators={backendValidators}
isQuerySaving={isQuerySaving}
isQueryUpdating={isQueryUpdating}
/>
</div>
);