diff --git a/articles/the-future-of-device-management-ep2.md b/articles/the-future-of-device-management-ep2.md index ff2e43b4a..215db4f49 100644 --- a/articles/the-future-of-device-management-ep2.md +++ b/articles/the-future-of-device-management-ep2.md @@ -29,6 +29,6 @@ In today’s episode of the Future of Device Management podcast, we speak with [ - + diff --git a/changes/issue-5829-improve-loading-states b/changes/issue-5829-improve-loading-states new file mode 100644 index 000000000..e3b3714c2 --- /dev/null +++ b/changes/issue-5829-improve-loading-states @@ -0,0 +1 @@ +* Improve UI loading states. \ No newline at end of file diff --git a/cypress/integration/all/app/policiesflow.spec.ts b/cypress/integration/all/app/policiesflow.spec.ts index ac0993b7e..466565ff0 100644 --- a/cypress/integration/all/app/policiesflow.spec.ts +++ b/cypress/integration/all/app/policiesflow.spec.ts @@ -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 diff --git a/cypress/integration/premium/admin.spec.ts b/cypress/integration/premium/admin.spec.ts index ced21f1ff..1f791b13d 100644 --- a/cypress/integration/premium/admin.spec.ts +++ b/cypress/integration/premium/admin.spec.ts @@ -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"); }); diff --git a/cypress/integration/premium/maintainer.spec.ts b/cypress/integration/premium/maintainer.spec.ts index e3ebc989e..4e374c4e8 100644 --- a/cypress/integration/premium/maintainer.spec.ts +++ b/cypress/integration/premium/maintainer.spec.ts @@ -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", () => { diff --git a/cypress/integration/premium/team_admin.spec.ts b/cypress/integration/premium/team_admin.spec.ts index f268a3af4..7fc239a25 100644 --- a/cypress/integration/premium/team_admin.spec.ts +++ b/cypress/integration/premium/team_admin.spec.ts @@ -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", () => { diff --git a/cypress/integration/premium/team_maintainer_observer.spec.ts b/cypress/integration/premium/team_maintainer_observer.spec.ts index 3d2260f0c..aa45ba3b1 100644 --- a/cypress/integration/premium/team_maintainer_observer.spec.ts +++ b/cypress/integration/premium/team_maintainer_observer.spec.ts @@ -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 diff --git a/frontend/components/LiveQuery/SelectTargets.tsx b/frontend/components/LiveQuery/SelectTargets.tsx index ad0ca9e1d..ac98c0b78 100644 --- a/frontend/components/LiveQuery/SelectTargets.tsx +++ b/frontend/components/LiveQuery/SelectTargets.tsx @@ -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 Checking for online hosts...; + return ( + <> + + Counting hosts + + ); } if (errorCounts) { return ( - There was a problem checking online hosts. Please try again later. + There was a problem counting hosts. Please try again later. ); } diff --git a/frontend/components/Spinner/Spinner.stories.tsx b/frontend/components/Spinner/Spinner.stories.tsx index 15df93a38..f6a309328 100644 --- a/frontend/components/Spinner/Spinner.stories.tsx +++ b/frontend/components/Spinner/Spinner.stories.tsx @@ -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 = (props) => ; +const Template: Story = (props) => ; export const Default = Template.bind({}); diff --git a/frontend/components/Spinner/Spinner.tsx b/frontend/components/Spinner/Spinner.tsx index 1973cae5f..bcdf8bcd3 100644 --- a/frontend/components/Spinner/Spinner.tsx +++ b/frontend/components/Spinner/Spinner.tsx @@ -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 ( -
-
-
-
-
-
- ); - } - +const Spinner = ({ small }: ISpinnerProps): JSX.Element => { return ( -
+
diff --git a/frontend/components/Spinner/_styles.scss b/frontend/components/Spinner/_styles.scss index f11ae4b31..b056f7963 100644 --- a/frontend/components/Spinner/_styles.scss +++ b/frontend/components/Spinner/_styles.scss @@ -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); diff --git a/frontend/pages/policies/PolicyPage/_styles.scss b/frontend/pages/policies/PolicyPage/_styles.scss index 50a75dc6a..183da66e7 100644 --- a/frontend/pages/policies/PolicyPage/_styles.scss +++ b/frontend/pages/policies/PolicyPage/_styles.scss @@ -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; diff --git a/frontend/pages/policies/PolicyPage/components/NewPolicyModal/NewPolicyModal.tsx b/frontend/pages/policies/PolicyPage/components/NewPolicyModal/NewPolicyModal.tsx index 68b165ecb..ffb684ab3 100644 --- a/frontend/pages/policies/PolicyPage/components/NewPolicyModal/NewPolicyModal.tsx +++ b/frontend/pages/policies/PolicyPage/components/NewPolicyModal/NewPolicyModal.tsx @@ -102,84 +102,80 @@ const NewPolicyModal = ({ return ( setIsNewPolicyModalOpen(false)}> <> - {policyIsLoading ? ( - - ) : ( -
+ 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?" + /> + setDescription(value)} + value={description} + inputClassName={`${baseClass}__policy-save-modal-description`} + label="Description" + placeholder="Add a description here (optional)" + /> + 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()} +
- 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?" - /> - setDescription(value)} - value={description} - inputClassName={`${baseClass}__policy-save-modal-description`} - label="Description" - placeholder="Add a description here (optional)" - /> - 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()} -
setIsNewPolicyModalOpen(false)} + variant="text-link" + > + Cancel + + - - - - Select the platform(s) this -
- policy will be checked on -
- to save the policy. -
-
-
- - )} + Select the platform(s) this +
+ policy will be checked on +
+ to save the policy. + + +
+
); diff --git a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx index 76bcfe38b..48711460b 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx +++ b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx @@ -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( false ); + const [isPolicySaving, setIsPolicySaving] = useState(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 ? : "Save"} )}
); diff --git a/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx b/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx index 58556f657..dc0f496f9 100644 --- a/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx +++ b/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx @@ -70,6 +70,7 @@ const QueryEditor = ({ const [isCreatingNewPolicy, setIsCreatingNewPolicy] = useState( false ); + const [isUpdatingPolicy, setIsUpdatingPolicy] = useState(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} />
); diff --git a/frontend/pages/queries/QueryPage/_styles.scss b/frontend/pages/queries/QueryPage/_styles.scss index d6b00a864..df7b812ad 100644 --- a/frontend/pages/queries/QueryPage/_styles.scss +++ b/frontend/pages/queries/QueryPage/_styles.scss @@ -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; diff --git a/frontend/pages/queries/QueryPage/components/NewQueryModal/NewQueryModal.tsx b/frontend/pages/queries/QueryPage/components/NewQueryModal/NewQueryModal.tsx index 7e64b5853..d53f98ca9 100644 --- a/frontend/pages/queries/QueryPage/components/NewQueryModal/NewQueryModal.tsx +++ b/frontend/pages/queries/QueryPage/components/NewQueryModal/NewQueryModal.tsx @@ -78,65 +78,61 @@ const NewQueryModal = ({ return ( setIsSaveModalOpen(false)}> <> - {isLoading ? ( - - ) : ( -
+ setName(value)} + value={name} + error={errors.name} + inputClassName={`${baseClass}__query-save-modal-name`} + label="Name" + placeholder="What is your query called?" + /> + setDescription(value)} + value={description} + inputClassName={`${baseClass}__query-save-modal-description`} + label="Description" + type="textarea" + placeholder="What information does your query reveal? (optional)" + /> + - setName(value)} - value={name} - error={errors.name} - inputClassName={`${baseClass}__query-save-modal-name`} - label="Name" - placeholder="What is your query called?" - /> - setDescription(value)} - value={description} - inputClassName={`${baseClass}__query-save-modal-description`} - label="Description" - type="textarea" - placeholder="What information does your query reveal? (optional)" - /> - +

+ Users with the Observer role will be able to run this query on hosts + where they have access. +

+
+
+ - -
- - )} + {isLoading ? : "Save query"} + +
+ ); diff --git a/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tsx b/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tsx index baced09c7..bfaadd877 100644 --- a/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tsx +++ b/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tsx @@ -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 = ( <>
- {isSaveAsNewLoading && ( -
- -
- )}
{renderName()} @@ -496,7 +493,7 @@ const QueryForm = ({ onClick={promptSaveAsNewQuery()} disabled={false} > - Save as new + {isSaveAsNewLoading ? : "Save as new"} )}
@@ -519,7 +516,7 @@ const QueryForm = ({ !hasTeamMaintainerPermissions } > - Save + {isQueryUpdating ? : "Save"}
{" "} - <> - - Stop - + <>Stop
); diff --git a/frontend/pages/queries/QueryPage/screens/QueryEditor.tsx b/frontend/pages/queries/QueryPage/screens/QueryEditor.tsx index 2ac340d01..4dd639d43 100644 --- a/frontend/pages/queries/QueryPage/screens/QueryEditor.tsx +++ b/frontend/pages/queries/QueryPage/screens/QueryEditor.tsx @@ -62,6 +62,7 @@ const QueryEditor = ({ } = useContext(QueryContext); const [isQuerySaving, setIsQuerySaving] = useState(false); + const [isQueryUpdating, setIsQueryUpdating] = useState(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} />
);