Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,7 @@ module.exports = {
moduleNameMapper: {
"\\.(scss|sass|css)$": "identity-obj-proxy",
},
globals: {
fetch: global.fetch,
},
};
31 changes: 31 additions & 0 deletions static/js/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,35 @@ declare interface Window {
MktoForms2: any;
Vimeo: any;
DNS_VERIFICATION_TOKEN: string;
SENTRY_DSN: string;
CSRF_TOKEN: string;
SNAP_PUBLICISE_DATA: {
hasScreenshot: boolean;
isReleased: boolean;
private: boolean;
trending: boolean;
};
SNAP_SETTINGS_DATA: {
blacklist_countries: string[];
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Inclusive naming check] reported by reviewdog 🐶
[warning] blacklist may be insensitive, use denylist, blocklist instead

blacklist_country_keys: string;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Inclusive naming check] reported by reviewdog 🐶
[warning] blacklist may be insensitive, use denylist, blocklist instead

countries: Array<{ key: string; name: string }>;
country_keys_status: string | null;
private: boolean;
publisher_name: string;
snap_id: string;
snap_name: string;
snap_title: string;
status: string;
store: string;
territory_distribution_status: string;
unlisted: boolean;
update_metadata_on_release: boolean;
visibility: string;
visibility_locked: boolean;
whitelist_countries: string[];
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Inclusive naming check] reported by reviewdog 🐶
[warning] whitelist may be insensitive, use allowlist instead

whitelist_country_keys: string;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Inclusive naming check] reported by reviewdog 🐶
[warning] whitelist may be insensitive, use allowlist instead

};
SNAP_LISTING_DATA: {
DNS_VERIFICATION_TOKEN: string;
};
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useLocation } from "react-router-dom";
import {
SideNavigation,
SideNavigationText,
Expand All @@ -12,6 +13,7 @@ function PrimaryNav({
collapseNavigation: boolean;
setCollapseNavigation: Function;
}): JSX.Element {
const location = useLocation();
const { data: publisherData } = usePublisher();

return (
Expand Down Expand Up @@ -47,7 +49,7 @@ function PrimaryNav({
label: "My validation sets",
href: "/validation-sets",
icon: "topic",
"aria-current": "page",
"aria-current": location.pathname.includes("/validation-sets"),
},
],
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";

import SaveAndPreview from "./SaveAndPreview";

const reset = jest.fn();

const renderComponent = (
isDirty: boolean,
isSaving: boolean,
isValid: boolean
) => {
return render(
<SaveAndPreview
snapName="test-snap-name"
isDirty={isDirty}
reset={reset}
isSaving={isSaving}
isValid={isValid}
/>
);
};

test("the 'Revert' button is disabled by default", () => {
renderComponent(false, false, true);
expect(screen.getByRole("button", { name: "Revert" })).toHaveAttribute("aria-disabled","true");
});

test("the 'Revert' button is enabled is data is dirty", () => {
renderComponent(true, false, true);
expect(screen.getByRole("button", { name: "Revert" })).not.toBeDisabled();
});

test("the 'Save' button is disabled by default", () => {
renderComponent(false, false, true);
expect(screen.getByRole("button", { name: "Save" })).toHaveAttribute("aria-disabled","true");
});

test("the 'Save' button is enabled is data is dirty", () => {
renderComponent(true, false, true);
expect(screen.getByRole("button", { name: "Save" })).not.toBeDisabled();
});

test("the 'Save' button shows loading state if saving", () => {
renderComponent(true, true, true);
expect(screen.getByRole("button", { name: "Saving" })).toBeInTheDocument();
});

test("the 'Save' button is disabled when saving", () => {
renderComponent(true, true, true);
expect(screen.getByRole("button", { name: "Saving" })).toHaveAttribute("aria-disabled","true");
});

test("the 'Save' button is disabled if the form is invalid", () => {
renderComponent(false, false, false);
expect(screen.getByRole("button", { name: "Save" })).toHaveAttribute("aria-disabled","true");
});

test("revert button resets the form", async () => {
const user = userEvent.setup();
renderComponent(true, false, true);
await user.click(screen.getByRole("button", { name: "Revert" }));
await waitFor(() => {
expect(reset).toHaveBeenCalled();
});
});
104 changes: 104 additions & 0 deletions static/js/publisher-pages/components/SaveAndPreview/SaveAndPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { useRef } from "react";
import { Row, Col, Button } from "@canonical/react-components";

import debounce from "../../../libs/debounce";

type Props = {
snapName: string;
isDirty: boolean;
reset: Function;
isSaving: boolean;
isValid: boolean;
showPreview?: boolean;
};

function SaveAndPreview({
snapName,
isDirty,
reset,
isSaving,
showPreview,
}: Props) {
const stickyBar = useRef<HTMLDivElement>(null);
const mainPanel = document.querySelector(".l-main") as HTMLElement;

const handleScroll = () => {
stickyBar?.current?.classList.toggle(
"sticky-shadow",
stickyBar?.current?.getBoundingClientRect()?.top === 0
);
};

if (mainPanel) {
mainPanel.addEventListener("scroll", debounce(handleScroll, 10, false));
}

return (
<>
<div
className="snapcraft-p-sticky js-sticky-bar"
ref={stickyBar}
style={{ margin: "0 -1.5rem", padding: "0 1.5rem" }}
>
<Row>
<Col size={7}>
<p className="u-no-margin--bottom">
Updates to this information will appear immediately on the{" "}
<a href={`/${snapName}`}>snap listing page</a>.
</p>
</Col>
<Col size={5}>
<div className="u-align--right">
{showPreview && (
<Button
type="submit"
className="p-button--base p-tooltip--btm-center"
aria-describedby="preview-tooltip"
form="preview-form"
>
Preview
<span
className="p-tooltip__message"
role="tooltip"
id="preview-tooltip"
>
Previews will only work in the same browser, locally
</span>
</Button>
)}
<Button
appearance="default"
disabled={!isDirty}
type="reset"
onClick={() => {
reset();
}}
>
Revert
</Button>
<Button
appearance="positive"
disabled={!isDirty || isSaving}
type="submit"
style={{ minWidth: "68px" }}
>
{isSaving ? (
<i className="p-icon--spinner is-light u-animation--spin">
Saving
</i>
) : (
"Save"
)}
</Button>
</div>
</Col>
</Row>
</div>
<div className="u-fixed-width">
<hr className="u-no-margin--bottom" />
</div>
</>
);
}

export default SaveAndPreview;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./SaveAndPreview";
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Notification } from "@canonical/react-components";

type Props = {
hasSaved: boolean;
setHasSaved: Function;
savedError: boolean | Array<{ message: string }>;
setSavedError: Function;
};

function SaveStateNotifications({
hasSaved,
setHasSaved,
savedError,
setSavedError,
}: Props) {
return (
<>
{hasSaved && (
<div className="u-fixed-width">
<Notification
severity="positive"
title="Changes applied successfully."
onDismiss={() => {
setHasSaved(false);
}}
/>
</div>
)}

{savedError && (
<div className="u-fixed-width">
<Notification
severity="negative"
title="Error"
onDismiss={() => {
setHasSaved(false);
setSavedError(false);
}}
>
Changes have not been saved.
<br />
{savedError === true
? "Something went wrong."
: savedError.map((error) => `${error.message}`).join("\n")}
</Notification>
</div>
)}
</>
);
}

export default SaveStateNotifications;
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { screen, render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";

import SaveStateNotifications from "../SaveStateNotifications";

type Options = {
hasSaved?: boolean;
setHasSaved?: Function;
savedError?: boolean | Array<{ message: string }>;
setSavedError?: Function;
};

const renderComponent = (options: Options) => {
return render(
<SaveStateNotifications
hasSaved={options.hasSaved || false}
setHasSaved={options.setHasSaved || jest.fn()}
savedError={options.savedError || false}
setSavedError={options.setSavedError || jest.fn()}
/>
);
};

describe("SaveStateNotifications", () => {
test("shows success notification if saved", () => {
renderComponent({ hasSaved: true });
expect(
screen.getByRole("heading", { name: "Changes applied successfully." })
).toBeInTheDocument();
});

test("doesn't show success notification if not saved", () => {
renderComponent({ hasSaved: false });
expect(
screen.queryByRole("heading", { name: "Changes applied successfully." })
).not.toBeInTheDocument();
});

test("success notifcation can be closed", async () => {
const user = userEvent.setup();
const setHasSaved = jest.fn();
renderComponent({ hasSaved: true, setHasSaved });
await user.click(
screen.getByRole("button", { name: "Close notification" })
);
expect(setHasSaved).toHaveBeenCalled();
});

test("shows error notification if saved", () => {
renderComponent({ savedError: true });
expect(
screen.getByText(/Changes have not been saved./)
).toBeInTheDocument();
});

test("doesn't show error notification if not saved", () => {
renderComponent({ savedError: false });
expect(
screen.queryByText(/Changes have not been saved./)
).not.toBeInTheDocument();
});

test("shows generic error if message is boolean", () => {
renderComponent({ savedError: true });
expect(screen.getByText(/Something went wrong./)).toBeInTheDocument();
});

test("shows custom error if message is an array", () => {
renderComponent({
savedError: [
{ message: "Saving error" },
{ message: "Field is required" },
],
});
expect(screen.getByText(/Saving error/)).toBeInTheDocument();
expect(screen.getByText(/Field is required/)).toBeInTheDocument();
});

test("error notifcation can be closed", async () => {
const user = userEvent.setup();
const setHasSaved = jest.fn();
renderComponent({ savedError: true, setHasSaved });
await user.click(
screen.getByRole("button", { name: "Close notification" })
);
expect(setHasSaved).toHaveBeenCalled();
});

test("error notifcation can be cleared", async () => {
const user = userEvent.setup();
const setSavedError = jest.fn();
renderComponent({ savedError: true, setSavedError });
await user.click(
screen.getByRole("button", { name: "Close notification" })
);
expect(setSavedError).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./SaveStateNotifications";
Loading
Loading