From e2003312bf063ecd6a22be71d6229516b6af1291 Mon Sep 17 00:00:00 2001 From: Steve Rydz Date: Mon, 9 Sep 2024 12:22:11 +0100 Subject: [PATCH 1/5] feat: Move publicise into publisher app (#4844) --- static/js/global.d.ts | 6 + .../components/PrimaryNav/PrimaryNav.tsx | 4 +- .../components/SectionNav/SectionNav.tsx | 48 +++++ .../components/SectionNav/SectionNavTest.tsx | 60 +++++++ .../components/SectionNav/index.ts | 1 + static/js/publisher-pages/index.tsx | 14 ++ .../pages/Publicise/Publicise.tsx | 89 ++++++++++ .../pages/Publicise/PubliciseBadges.tsx | 139 +++++++++++++++ .../pages/Publicise/PubliciseButtons.tsx | 168 ++++++++++++++++++ .../pages/Publicise/PubliciseCards.tsx | 154 ++++++++++++++++ .../Publicise/__tests__/Publicise.test.tsx | 73 ++++++++ .../publisher-pages/pages/Publicise/index.ts | 1 + static/sass/_snapcraft-publicise.scss | 26 --- static/sass/styles.scss | 2 - .../_publisher_publicise_layout.html | 55 ------ .../publisher/publicise/embedded_cards.html | 97 ---------- .../publisher/publicise/github_badges.html | 98 ---------- .../publisher/publicise/store_buttons.html | 148 --------------- templates/store/publisher.html | 8 + tests/publisher/snaps/tests_publicise.py | 14 +- .../publisher/snaps/tests_publicise_badges.py | 13 +- .../publisher/snaps/tests_publicise_cards.py | 16 +- webapp/publisher/snaps/publicise_views.py | 45 +---- 23 files changed, 773 insertions(+), 506 deletions(-) create mode 100644 static/js/publisher-pages/components/SectionNav/SectionNav.tsx create mode 100644 static/js/publisher-pages/components/SectionNav/SectionNavTest.tsx create mode 100644 static/js/publisher-pages/components/SectionNav/index.ts create mode 100644 static/js/publisher-pages/pages/Publicise/Publicise.tsx create mode 100644 static/js/publisher-pages/pages/Publicise/PubliciseBadges.tsx create mode 100644 static/js/publisher-pages/pages/Publicise/PubliciseButtons.tsx create mode 100644 static/js/publisher-pages/pages/Publicise/PubliciseCards.tsx create mode 100644 static/js/publisher-pages/pages/Publicise/__tests__/Publicise.test.tsx create mode 100644 static/js/publisher-pages/pages/Publicise/index.ts delete mode 100644 static/sass/_snapcraft-publicise.scss delete mode 100644 templates/publisher/publicise/_publisher_publicise_layout.html delete mode 100644 templates/publisher/publicise/embedded_cards.html delete mode 100644 templates/publisher/publicise/github_badges.html delete mode 100644 templates/publisher/publicise/store_buttons.html diff --git a/static/js/global.d.ts b/static/js/global.d.ts index 064031f2a1..e70af2015b 100644 --- a/static/js/global.d.ts +++ b/static/js/global.d.ts @@ -13,4 +13,10 @@ declare interface Window { MktoForms2: any; Vimeo: any; DNS_VERIFICATION_TOKEN: string; + SNAP_PUBLICISE_DATA: { + hasScreenshot: boolean; + isReleased: boolean; + private: boolean; + trending: boolean; + }; } diff --git a/static/js/publisher-pages/components/PrimaryNav/PrimaryNav.tsx b/static/js/publisher-pages/components/PrimaryNav/PrimaryNav.tsx index aee0e2d3ea..1858ee76c9 100644 --- a/static/js/publisher-pages/components/PrimaryNav/PrimaryNav.tsx +++ b/static/js/publisher-pages/components/PrimaryNav/PrimaryNav.tsx @@ -1,3 +1,4 @@ +import { useLocation } from "react-router-dom"; import { SideNavigation, SideNavigationText, @@ -12,6 +13,7 @@ function PrimaryNav({ collapseNavigation: boolean; setCollapseNavigation: Function; }): JSX.Element { + const location = useLocation(); const { data: publisherData } = usePublisher(); return ( @@ -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"), }, ], }, diff --git a/static/js/publisher-pages/components/SectionNav/SectionNav.tsx b/static/js/publisher-pages/components/SectionNav/SectionNav.tsx new file mode 100644 index 0000000000..fbd8ecfd45 --- /dev/null +++ b/static/js/publisher-pages/components/SectionNav/SectionNav.tsx @@ -0,0 +1,48 @@ +import { Tabs } from "@canonical/react-components"; + +type Props = { + activeTab: string; + snapName: string | undefined; +}; + +function SectionNav({ activeTab, snapName }: Props) { + return ( + + ); +} + +export default SectionNav; diff --git a/static/js/publisher-pages/components/SectionNav/SectionNavTest.tsx b/static/js/publisher-pages/components/SectionNav/SectionNavTest.tsx new file mode 100644 index 0000000000..11193c6ebe --- /dev/null +++ b/static/js/publisher-pages/components/SectionNav/SectionNavTest.tsx @@ -0,0 +1,60 @@ +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; + +import SectionNav from "./SectionNav"; + +const snapName = "test-snap-name"; + +const props = { + snapName: "test-snap-name", + activeTab: "listing", +}; + +test("the page displays the correct name for the snap", () => { + render(); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "test-snap-name" + ); +}); + +test("the 'Listing' tab has the correct path", () => { + render(); + expect( + screen.getByRole("link", { name: "Listing" }).getAttribute("href") + ).toBe(`/${snapName}/listing`); +}); + +test("the 'Builds' tab has the correct path", () => { + render(); + expect( + screen.getByRole("link", { name: "Builds" }).getAttribute("href") + ).toBe(`/${snapName}/builds`); +}); + +test("the 'Releases' tab has the correct path", () => { + render(); + expect( + screen.getByRole("link", { name: "Releases" }).getAttribute("href") + ).toBe(`/${snapName}/releases`); +}); + +test("the 'Metrics' tab has the correct path", () => { + render(); + expect( + screen.getByRole("link", { name: "Metrics" }).getAttribute("href") + ).toBe(`/${snapName}/metrics`); +}); + +test("the 'Publicise' tab has the correct path", () => { + render(); + expect( + screen.getByRole("link", { name: "Publicise" }).getAttribute("href") + ).toBe(`/${snapName}/publicise`); +}); + +test("the 'Settings' tab has the correct path", () => { + render(); + expect( + screen.getByRole("link", { name: "Settings" }).getAttribute("href") + ).toBe(`/${snapName}/settings`); +}); diff --git a/static/js/publisher-pages/components/SectionNav/index.ts b/static/js/publisher-pages/components/SectionNav/index.ts new file mode 100644 index 0000000000..8d0d108263 --- /dev/null +++ b/static/js/publisher-pages/components/SectionNav/index.ts @@ -0,0 +1 @@ +export { default } from "./SectionNav"; diff --git a/static/js/publisher-pages/index.tsx b/static/js/publisher-pages/index.tsx index c1275a3a62..e24a32c7b8 100644 --- a/static/js/publisher-pages/index.tsx +++ b/static/js/publisher-pages/index.tsx @@ -3,6 +3,7 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "react-query"; import Root from "./routes/root"; +import Publicise from "./pages/Publicise"; import ValidationSets from "./pages/ValidationSets"; import ValidationSet from "./pages/ValidationSet"; @@ -11,6 +12,19 @@ const router = createBrowserRouter([ path: "/", element: , children: [ + { + path: "/:snapId/publicise", + element: , + }, + { + path: "/:snapId/publicise/badges", + element: , + }, + { + path: "/:snapId/publicise/cards", + element: , + }, + { path: "/validation-sets", element: , diff --git a/static/js/publisher-pages/pages/Publicise/Publicise.tsx b/static/js/publisher-pages/pages/Publicise/Publicise.tsx new file mode 100644 index 0000000000..770f4222c0 --- /dev/null +++ b/static/js/publisher-pages/pages/Publicise/Publicise.tsx @@ -0,0 +1,89 @@ +import { useParams, NavLink, Link } from "react-router-dom"; +import { + Row, + Col, + SideNavigation, + Notification, +} from "@canonical/react-components"; + +import SectionNav from "../../components/SectionNav"; +import PubliciseButtons from "./PubliciseButtons"; +import PubliciseBadges from "./PubliciseBadges"; +import PubliciseCards from "./PubliciseCards"; + +type Props = { + view?: undefined | "badges" | "cards"; +}; + +function Publicise({ view }: Props): JSX.Element { + const { snapId } = useParams(); + + const disableView = () => { + if (window.SNAP_PUBLICISE_DATA.private) { + return true; + } + + if (!window.SNAP_PUBLICISE_DATA.isReleased) { + return true; + } + + return false; + }; + + return ( + <> +

+ My snaps / {snapId} / + Publicise +

+ + + + {disableView() && ( + + When your snap is public and has a release, you'll be able to share it + using Store buttons, badges and embeddable cards. Make your snap + public in its settings page. + + )} + + + + + + + {!view && } + {view === "badges" && } + {view === "cards" && } + + + + ); +} + +export default Publicise; diff --git a/static/js/publisher-pages/pages/Publicise/PubliciseBadges.tsx b/static/js/publisher-pages/pages/Publicise/PubliciseBadges.tsx new file mode 100644 index 0000000000..7794dba5e8 --- /dev/null +++ b/static/js/publisher-pages/pages/Publicise/PubliciseBadges.tsx @@ -0,0 +1,139 @@ +import { useState, SyntheticEvent } from "react"; +import { useParams } from "react-router-dom"; +import { + Row, + Col, + CheckboxInput, + Notification, +} from "@canonical/react-components"; + +function PubliciseBadges(): JSX.Element { + const { snapId } = useParams(); + const [showStableChannelBadge, setShowStableChannelBadge] = + useState(true); + const [showTrendingStatusBadge, setShowTrendingStatusBadge] = + useState(false); + + const showPreview: boolean = + showStableChannelBadge || showTrendingStatusBadge; + + const htmlSnippetStable = ` + ${snapId} +`; + + const htmlSnippetTrending = ` + ${snapId} +`; + + const markdownSnippetStable = `[![${snapId}](https://snapcraft.io/${snapId}/badge.svg)](https://snapcraft.io/${snapId})`; + + const markdownSnippetTrending = `[![${snapId}](https://snapcraft.io/${snapId}/trending.svg?name=0)](https://snapcraft.io/${snapId})`; + + return ( + <> + + + + + + & { target: HTMLInputElement } + ) => { + setShowStableChannelBadge(e.target.checked); + }} + /> + & { target: HTMLInputElement } + ) => { + setShowTrendingStatusBadge(e.target.checked); + }} + /> +

+ + Badge will only display when your snap is flagged as trending + +

+ {!showStableChannelBadge && !showTrendingStatusBadge && ( +

+ + Please select at least one badge to display from the list above + +

+ )} + +
+ {showPreview && ( + <> + + + + + +

+ {showStableChannelBadge && ( + + {snapId} + + )}{" "} + {showTrendingStatusBadge && ( + + {snapId} + + )} +

+ + {!window?.SNAP_PUBLICISE_DATA?.trending && + showTrendingStatusBadge && ( + + Your snap is not currently flagged as trending. Only when + your snap becomes trending will the trending badge appear on + external sites. + + )} + +
+ + + + + +
+
+                  {showStableChannelBadge && htmlSnippetStable}
+                  
+ {showTrendingStatusBadge && htmlSnippetTrending} +
+
+ +
+ + + + + +
+
+                  {showStableChannelBadge && markdownSnippetStable}
+                  
+ {showTrendingStatusBadge && markdownSnippetTrending} +
+
+ +
+ + )} + + ); +} + +export default PubliciseBadges; diff --git a/static/js/publisher-pages/pages/Publicise/PubliciseButtons.tsx b/static/js/publisher-pages/pages/Publicise/PubliciseButtons.tsx new file mode 100644 index 0000000000..8a9c205c9e --- /dev/null +++ b/static/js/publisher-pages/pages/Publicise/PubliciseButtons.tsx @@ -0,0 +1,168 @@ +import { useState } from "react"; +import { useParams } from "react-router-dom"; +import { Row, Col, Select } from "@canonical/react-components"; + +const LANGUAGES = { + ar: { title: "العربية", text: "احصل عليه من Snap Store" }, + bg: { title: "български", text: "Инсталирайте го от Snap Store" }, + bn: { title: "বাংলা", text: "থেকে ইনস্টল করুন" }, + de: { title: "Deutsch", text: "Installieren vom Snap Store" }, + en: { title: "English", text: "Get it from the Snap Store" }, + es: { title: "Español", text: "Instalar desde Snap Store" }, + fr: { title: "Français", text: "Installer à partir du Snap Store" }, + it: { title: "Italiano", text: "Scarica dallo Snap Store" }, + jp: { title: "日本語", text: "Snap Store から入手ください" }, + pl: { title: "Polski", text: "Pobierz w Snap Store" }, + pt: { title: "Português", text: "Disponível na Snap Store" }, + ro: { title: "Română", text: "Instalează din Snap Store" }, + ru: { title: "русский язык", text: "Загрузите из Snap Store" }, + tw: { title: "中文(台灣)", text: "安裝軟體敬請移駕 Snap Store" }, +}; + +function PubliciseButtons(): JSX.Element { + const { snapId } = useParams(); + const [selectedLanguage, setSelectedLanguage] = useState("en"); + + const htmlSnippetBlack = ` + Get it from the Snap Store + +`; + + const htmlSnippetWhite = ` + Get it from the Snap Store + +`; + + const markdownSnippetBlack = `[![Get it from the Snap Store](https://snapcraft.io/static/images/badges/${selectedLanguage}/snap-store-black.svg)](https://snapcraft.io/${snapId}) +`; + + const markdownSnippetWhite = `[![Get it from the Snap Store](https://snapcraft.io/static/images/badges/${selectedLanguage}/snap-store-white.svg)](https://snapcraft.io/${snapId}) +`; + + return ( + <> + + + + + + - - -
- - -
-
- - -
- - - -
-
-
- Options: -
-
-
- - -
-
- - -
-
- - -
-
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
<iframe src="https://snapcraft.io/{{ snap_name }}/embedded?button=black&channels=true&summary=true&screenshot=true" frameborder="0" width="100%" height="320px" style="border: 1px solid #CCC; border-radius: 2px;"></iframe>
-
-
-
-{% endblock %} - -{% block scripts_includes %} - -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/templates/publisher/publicise/github_badges.html b/templates/publisher/publicise/github_badges.html deleted file mode 100644 index 0d29ecdd43..0000000000 --- a/templates/publisher/publicise/github_badges.html +++ /dev/null @@ -1,98 +0,0 @@ -{% set publicise_page="badges" %} -{% extends "publisher/publicise/_publisher_publicise_layout.html" %} - -{% block publicise_content %} -
-

Promote your snap using embeddable GitHub badge

-
- -
-
- Display: -
-
-
- - -
-
- - -
-

Please select at least one badge to display from the list above

-
-
- -
-
-
- Preview: -
-
-

- - {{ snap_title }} - -

- - -
-
- -
-
- -
-
-
-
<a href="https://snapcraft.io/{{ snap_name }}"><img alt="{{ snap_title }}" src="https://snapcraft.io/{{ snap_name }}/badge.svg" /></a>
-
-
-
- -
-
- -
-
-
-
[![{{ snap_title }}](https://snapcraft.io/{{ snap_name }}/badge.svg)](https://snapcraft.io/{{ snap_name }})
-
-
-
-
-{% endblock %} - -{% block scripts_includes %} - -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/templates/publisher/publicise/store_buttons.html b/templates/publisher/publicise/store_buttons.html deleted file mode 100644 index cd9c5c7864..0000000000 --- a/templates/publisher/publicise/store_buttons.html +++ /dev/null @@ -1,148 +0,0 @@ -{% set publicise_page="buttons" %} -{% extends "publisher/publicise/_publisher_publicise_layout.html" %} - -{% block publicise_content %} -
-

Promote your snap using Snap Store badges

-
-
-
- -
-
-
- -
-

You can help translate these buttons in this repository.

-
-
- -
-
-
- - {% for lang, data in available.items() %} -
-
-
-
-
-
-
-

- {% set url = "images/badges/" + lang + "/snap-store-black.svg" %} - - {{ data.text }} - -

-
-
-
-
- -
-
- -
-
-
-
<a href="https://snapcraft.io/{{ snap_name }}">
-  <img alt="{{ data.text }}" src="https://snapcraft.io/static/images/badges/{{ lang }}/snap-store-black.svg" />
-</a>
-
-
-
- -
-
- -
-
-
-
[![{{ data.text }}](https://snapcraft.io/static/images/badges/{{ lang }}/snap-store-black.svg)](https://snapcraft.io/{{ snap_name }})
-
-
-
- -
-
-
- -
-
-
-
-
-
-

- {% set url = "images/badges/" + lang + "/snap-store-white.svg" %} - - {{ data.text }} - -

-
-
-
-
- -
-
- -
-
-
-
<a href="https://snapcraft.io/{{ snap_name }}">
-  <img alt="{{ data.text }}" src="https://snapcraft.io/static/images/badges/{{ lang }}/snap-store-white.svg" />
-</a>
-
-
-
- -
-
- -
-
-
-
[![{{ data.text }}](https://snapcraft.io/static/images/badges/{{ lang }}/snap-store-white.svg)](https://snapcraft.io/{{ snap_name }})
-
-
-
-
- {% endfor %} - -
-
-
- -
-
- Download all: -
- -
-{% endblock %} - -{% block scripts_includes %} - -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/templates/store/publisher.html b/templates/store/publisher.html index de4a3386a5..68c4fd4e28 100644 --- a/templates/store/publisher.html +++ b/templates/store/publisher.html @@ -3,5 +3,13 @@ {% block content %}
+ {% endblock %} diff --git a/tests/publisher/snaps/tests_publicise.py b/tests/publisher/snaps/tests_publicise.py index ab51692332..dd1d22d7ef 100644 --- a/tests/publisher/snaps/tests_publicise.py +++ b/tests/publisher/snaps/tests_publicise.py @@ -43,13 +43,9 @@ def test_publicise_logged_in(self): snap_name = "test-snap" payload = { - "snap_id": "id", - "title": "test snap", "private": False, "snap_name": snap_name, "channel_maps_list": [], - "keywords": [], - "publisher": {"display-name": "test"}, } responses.add(responses.GET, self.api_url, json=payload, status=200) @@ -59,10 +55,8 @@ def test_publicise_logged_in(self): self.check_call_by_api_url(responses.calls) assert response.status_code == 200 - self.assert_template_used("publisher/publicise/store_buttons.html") + self.assert_template_used("store/publisher.html") - self.assert_context("snap_id", "id") - self.assert_context("snap_title", "test snap") self.assert_context("snap_name", snap_name) @responses.activate @@ -70,13 +64,9 @@ def test_publicise_private_snap(self): snap_name = "test-snap" payload = { - "snap_id": "id", - "title": "test snap", "private": True, "snap_name": snap_name, "channel_maps_list": [], - "keywords": [], - "publisher": {"display-name": "test"}, } responses.add(responses.GET, self.api_url, json=payload, status=200) @@ -86,6 +76,6 @@ def test_publicise_private_snap(self): self.check_call_by_api_url(responses.calls) assert response.status_code == 200 - self.assert_template_used("publisher/publicise/store_buttons.html") + self.assert_template_used("store/publisher.html") self.assert_context("private", True) diff --git a/tests/publisher/snaps/tests_publicise_badges.py b/tests/publisher/snaps/tests_publicise_badges.py index 0aed3811e1..864a439cc3 100644 --- a/tests/publisher/snaps/tests_publicise_badges.py +++ b/tests/publisher/snaps/tests_publicise_badges.py @@ -76,13 +76,8 @@ def test_publicise_logged_in(self): snap_name = "test-snap" payload = { - "snap_id": "id", - "title": "test snap", "private": False, "snap_name": snap_name, - "keywords": [], - "media": [], - "publisher": {"display-name": "test"}, } responses.add(responses.GET, self.api_url, json=payload, status=200) @@ -98,10 +93,8 @@ def test_publicise_logged_in(self): self.check_call_by_api_url(responses.calls) self.assertEqual(response.status_code, 200) - self.assert_template_used("publisher/publicise/github_badges.html") + self.assert_template_used("store/publisher.html") - self.assert_context("snap_id", "id") - self.assert_context("snap_title", "test snap") self.assert_context("snap_name", snap_name) @responses.activate @@ -109,12 +102,8 @@ def test_publicise_private_snap(self): snap_name = "test-snap" payload = { - "snap_id": "id", - "title": "test snap", "private": True, "snap_name": snap_name, - "keywords": [], - "media": [], } responses.add(responses.GET, self.api_url, json=payload, status=200) diff --git a/tests/publisher/snaps/tests_publicise_cards.py b/tests/publisher/snaps/tests_publicise_cards.py index 9b716fbc0f..a39f7377d3 100644 --- a/tests/publisher/snaps/tests_publicise_cards.py +++ b/tests/publisher/snaps/tests_publicise_cards.py @@ -43,13 +43,9 @@ def test_publicise_logged_in(self): snap_name = "test-snap" payload = { - "snap_id": "id", - "title": "test snap", "private": False, "snap_name": snap_name, - "keywords": [], "media": [], - "publisher": {"display-name": "test"}, } responses.add(responses.GET, self.api_url, json=payload, status=200) @@ -59,10 +55,8 @@ def test_publicise_logged_in(self): self.check_call_by_api_url(responses.calls) self.assertEqual(response.status_code, 200) - self.assert_template_used("publisher/publicise/embedded_cards.html") + self.assert_template_used("store/publisher.html") - self.assert_context("snap_id", "id") - self.assert_context("snap_title", "test snap") self.assert_context("snap_name", snap_name) self.assert_context("has_screenshot", False) @@ -71,13 +65,9 @@ def test_publicise_snap_with_screenshot(self): snap_name = "test-snap" payload = { - "snap_id": "id", - "title": "test snap", "private": False, "snap_name": snap_name, - "keywords": [], "media": [{"url": "this is a url", "type": "screenshot"}], - "publisher": {"display-name": "test"}, } responses.add(responses.GET, self.api_url, json=payload, status=200) @@ -87,7 +77,7 @@ def test_publicise_snap_with_screenshot(self): self.check_call_by_api_url(responses.calls) self.assertEqual(response.status_code, 200) - self.assert_template_used("publisher/publicise/embedded_cards.html") + self.assert_template_used("store/publisher.html") self.assert_context("has_screenshot", True) @@ -96,11 +86,9 @@ def test_publicise_private_snap(self): snap_name = "test-snap" payload = { - "snap_id": "id", "title": "test snap", "private": True, "snap_name": snap_name, - "keywords": [], "media": [], } diff --git a/webapp/publisher/snaps/publicise_views.py b/webapp/publisher/snaps/publicise_views.py index e6c9b3efb8..65c30a4364 100644 --- a/webapp/publisher/snaps/publicise_views.py +++ b/webapp/publisher/snaps/publicise_views.py @@ -20,40 +20,13 @@ def get_publicise(snap_name): is_released = len(snap_details["channel_maps_list"]) > 0 - available_languages = { - "ar": {"title": "العربية", "text": "احصل عليه من Snap Store"}, - "bg": {"title": "български", "text": "Инсталирайте го от Snap Store"}, - "bn": {"title": "বাংলা", "text": "থেকে ইনস্টল করুন"}, - "de": {"title": "Deutsch", "text": "Installieren vom Snap Store"}, - "en": {"title": "English", "text": "Get it from the Snap Store"}, - "es": {"title": "Español", "text": "Instalar desde Snap Store"}, - "fr": { - "title": "Français", - "text": "Installer à partir du Snap Store", - }, - "it": {"title": "Italiano", "text": "Scarica dallo Snap Store"}, - "jp": {"title": "日本語", "text": "Snap Store から入手ください"}, - "pl": {"title": "Polski", "text": "Pobierz w Snap Store"}, - "pt": {"title": "Português", "text": "Disponível na Snap Store"}, - "ro": {"title": "Română", "text": "Instalează din Snap Store"}, - "ru": {"title": "русский язык", "text": "Загрузите из Snap Store"}, - "tw": {"title": "中文(台灣)", "text": "安裝軟體敬請移駕 Snap Store"}, - } - context = { "private": snap_details["private"], "snap_name": snap_details["snap_name"], - "snap_title": snap_details["title"], - "publisher_name": snap_details["publisher"]["display-name"], - "snap_id": snap_details["snap_id"], - "is_release": is_released, - "available": available_languages, - "download_version": "v1.4.2", + "is_released": is_released, } - return flask.render_template( - "publisher/publicise/store_buttons.html", **context - ) + return flask.render_template("store/publisher.html", **context) @login_required @@ -67,15 +40,10 @@ def get_publicise_badges(snap_name): context = { "snap_name": snap_details["snap_name"], - "snap_title": snap_details["title"], - "publisher_name": snap_details["publisher"]["display-name"], - "snap_id": snap_details["snap_id"], "trending": snap_public_details["snap"]["trending"], } - return flask.render_template( - "publisher/publicise/github_badges.html", **context - ) + return flask.render_template("store/publisher.html", **context) @login_required @@ -91,11 +59,6 @@ def get_publicise_cards(snap_name): context = { "has_screenshot": has_screenshot, "snap_name": snap_details["snap_name"], - "snap_title": snap_details["title"], - "publisher_name": snap_details["publisher"]["display-name"], - "snap_id": snap_details["snap_id"], } - return flask.render_template( - "publisher/publicise/embedded_cards.html", **context - ) + return flask.render_template("store/publisher.html", **context) From 412e951b080726b7595576e64a50d9b1e600e466 Mon Sep 17 00:00:00 2001 From: Steve Rydz Date: Tue, 10 Sep 2024 11:14:56 +0100 Subject: [PATCH 2/5] feat: Move settings into publisher app (#4847) --- static/js/global.d.ts | 22 +++ .../SaveAndPreview/SaveAndPreview.test.tsx | 67 ++++++++ .../SaveAndPreview/SaveAndPreview.tsx | 98 ++++++++++++ .../components/SaveAndPreview/index.ts | 1 + .../SaveStateNotifications.tsx | 52 +++++++ .../__tests__/SaveStateNotifications.test.tsx | 99 ++++++++++++ .../SaveStateNotifications/index.ts | 1 + .../SearchAutocomplete/SearchAutocomplete.tsx | 144 ++++++++++++++++++ .../components/SearchAutocomplete/index.ts | 1 + .../components/SectionNav/SectionNav.tsx | 8 +- .../UpdateMetadataModal.tsx | 53 +++++++ .../__tests__/UpdateMetadataModal.test.tsx | 40 +++++ .../components/UpdateMetadataModal/index.ts | 1 + static/js/publisher-pages/index.tsx | 6 +- .../pages/Settings/Settings.tsx} | 34 +++-- .../pages/Settings}/UnregisterSnapModal.tsx | 0 .../__tests__/UnregisterSnapModal.test.tsx | 108 +++++++++++++ .../publisher-pages/pages/Settings/index.ts | 1 + static/js/publisher-pages/types/index.d.ts | 21 +++ .../utils/getChanges.ts | 0 .../utils/getFormData.ts | 2 +- .../utils/getSettingsData.ts | 7 +- .../utils/index.ts | 0 .../__tests__/UnregisterSnapModal.test.tsx | 97 ------------ .../settings/components/App/index.ts | 1 - static/js/publisher/settings/index.tsx | 14 -- .../settings/types/SettingsData.d.ts | 21 --- static/js/publisher/settings/types/index.d.ts | 12 -- templates/publisher/settings.html | 31 ---- templates/store/publisher.html | 18 +++ tests/publisher/snaps/tests_post_settings.py | 6 +- tests/publisher/snaps/tests_settings.py | 2 +- webapp/publisher/snaps/settings_views.py | 4 +- webpack.config.entry.js | 1 - 34 files changed, 765 insertions(+), 208 deletions(-) create mode 100644 static/js/publisher-pages/components/SaveAndPreview/SaveAndPreview.test.tsx create mode 100644 static/js/publisher-pages/components/SaveAndPreview/SaveAndPreview.tsx create mode 100644 static/js/publisher-pages/components/SaveAndPreview/index.ts create mode 100644 static/js/publisher-pages/components/SaveStateNotifications/SaveStateNotifications.tsx create mode 100644 static/js/publisher-pages/components/SaveStateNotifications/__tests__/SaveStateNotifications.test.tsx create mode 100644 static/js/publisher-pages/components/SaveStateNotifications/index.ts create mode 100644 static/js/publisher-pages/components/SearchAutocomplete/SearchAutocomplete.tsx create mode 100644 static/js/publisher-pages/components/SearchAutocomplete/index.ts create mode 100644 static/js/publisher-pages/components/UpdateMetadataModal/UpdateMetadataModal.tsx create mode 100644 static/js/publisher-pages/components/UpdateMetadataModal/__tests__/UpdateMetadataModal.test.tsx create mode 100644 static/js/publisher-pages/components/UpdateMetadataModal/index.ts rename static/js/{publisher/settings/components/App/App.tsx => publisher-pages/pages/Settings/Settings.tsx} (95%) rename static/js/{publisher/settings/components => publisher-pages/pages/Settings}/UnregisterSnapModal.tsx (100%) create mode 100644 static/js/publisher-pages/pages/Settings/__tests__/UnregisterSnapModal.test.tsx create mode 100644 static/js/publisher-pages/pages/Settings/index.ts rename static/js/{publisher/settings => publisher-pages}/utils/getChanges.ts (100%) rename static/js/{publisher/settings => publisher-pages}/utils/getFormData.ts (96%) rename static/js/{publisher/settings => publisher-pages}/utils/getSettingsData.ts (86%) rename static/js/{publisher/settings => publisher-pages}/utils/index.ts (100%) delete mode 100644 static/js/publisher/settings/components/App/__tests__/UnregisterSnapModal.test.tsx delete mode 100644 static/js/publisher/settings/components/App/index.ts delete mode 100644 static/js/publisher/settings/index.tsx delete mode 100644 static/js/publisher/settings/types/SettingsData.d.ts delete mode 100644 static/js/publisher/settings/types/index.d.ts delete mode 100644 templates/publisher/settings.html diff --git a/static/js/global.d.ts b/static/js/global.d.ts index e70af2015b..c7ca56fe19 100644 --- a/static/js/global.d.ts +++ b/static/js/global.d.ts @@ -13,10 +13,32 @@ 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[]; + blacklist_country_keys: string; + 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[]; + whitelist_country_keys: string; + }; } diff --git a/static/js/publisher-pages/components/SaveAndPreview/SaveAndPreview.test.tsx b/static/js/publisher-pages/components/SaveAndPreview/SaveAndPreview.test.tsx new file mode 100644 index 0000000000..7e091f5b89 --- /dev/null +++ b/static/js/publisher-pages/components/SaveAndPreview/SaveAndPreview.test.tsx @@ -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( + + ); +}; + +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(); + }); +}); diff --git a/static/js/publisher-pages/components/SaveAndPreview/SaveAndPreview.tsx b/static/js/publisher-pages/components/SaveAndPreview/SaveAndPreview.tsx new file mode 100644 index 0000000000..a6b71a5685 --- /dev/null +++ b/static/js/publisher-pages/components/SaveAndPreview/SaveAndPreview.tsx @@ -0,0 +1,98 @@ +import { useEffect, 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(null); + const handleScroll = () => { + stickyBar?.current?.classList.toggle( + "sticky-shadow", + stickyBar?.current?.getBoundingClientRect()?.top === 0 + ); + }; + + useEffect(() => { + document.addEventListener("scroll", debounce(handleScroll, 10, false)); + }, []); + + return ( + <> +
+ + +

+ Updates to this information will appear immediately on the{" "} + snap listing page. +

+ + +
+ {showPreview && ( + + )} + + +
+ +
+
+
+
+
+ + ); +} + +export default SaveAndPreview; diff --git a/static/js/publisher-pages/components/SaveAndPreview/index.ts b/static/js/publisher-pages/components/SaveAndPreview/index.ts new file mode 100644 index 0000000000..49f6ef4f1e --- /dev/null +++ b/static/js/publisher-pages/components/SaveAndPreview/index.ts @@ -0,0 +1 @@ +export { default } from "./SaveAndPreview"; diff --git a/static/js/publisher-pages/components/SaveStateNotifications/SaveStateNotifications.tsx b/static/js/publisher-pages/components/SaveStateNotifications/SaveStateNotifications.tsx new file mode 100644 index 0000000000..ad7e351067 --- /dev/null +++ b/static/js/publisher-pages/components/SaveStateNotifications/SaveStateNotifications.tsx @@ -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 && ( +
+ { + setHasSaved(false); + }} + /> +
+ )} + + {savedError && ( +
+ { + setHasSaved(false); + setSavedError(false); + }} + > + Changes have not been saved. +
+ {savedError === true + ? "Something went wrong." + : savedError.map((error) => `${error.message}`).join("\n")} +
+
+ )} + + ); +} + +export default SaveStateNotifications; diff --git a/static/js/publisher-pages/components/SaveStateNotifications/__tests__/SaveStateNotifications.test.tsx b/static/js/publisher-pages/components/SaveStateNotifications/__tests__/SaveStateNotifications.test.tsx new file mode 100644 index 0000000000..a0f1ff20fb --- /dev/null +++ b/static/js/publisher-pages/components/SaveStateNotifications/__tests__/SaveStateNotifications.test.tsx @@ -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( + + ); +}; + +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(); + }); +}); diff --git a/static/js/publisher-pages/components/SaveStateNotifications/index.ts b/static/js/publisher-pages/components/SaveStateNotifications/index.ts new file mode 100644 index 0000000000..bc2dca163f --- /dev/null +++ b/static/js/publisher-pages/components/SaveStateNotifications/index.ts @@ -0,0 +1 @@ +export { default } from "./SaveStateNotifications"; diff --git a/static/js/publisher-pages/components/SearchAutocomplete/SearchAutocomplete.tsx b/static/js/publisher-pages/components/SearchAutocomplete/SearchAutocomplete.tsx new file mode 100644 index 0000000000..69e492e6b3 --- /dev/null +++ b/static/js/publisher-pages/components/SearchAutocomplete/SearchAutocomplete.tsx @@ -0,0 +1,144 @@ +import { useState, useEffect } from "react"; +import Downshift from "downshift"; +import { useWatch } from "react-hook-form"; + +type DataItem = { + key: string; + name: string; +}; + +type Props = { + data: Array; + field: string; + currentValues: Array; + register: Function; + setValue: Function; + getValues: Function; + control: any; + disabled?: boolean; +}; + +function SearchAutocomplete({ + data, + field, + currentValues, + register, + setValue, + getValues, + control, + disabled, +}: Props) { + const [selections, setSelections] = useState(() => { + return data.filter((value) => currentValues.includes(value.key)); + }); + + const getNewSelectionKeys = (newSelections: Array) => { + return newSelections + .map((selection: DataItem) => selection.key) + .sort() + .join(" "); + }; + + const shouldDirty = (newSelectionsKeys: string) => { + return newSelectionsKeys !== getValues(field); + }; + + const selectionKeyValues = useWatch({ + control, + name: field, + }); + + useEffect(() => { + setSelections(() => { + return data.filter((value) => selectionKeyValues.includes(value.key)); + }); + }, [selectionKeyValues]); + + return ( + { + const newSelections = selections.concat([selection]); + const newSelectionsKeys = getNewSelectionKeys(newSelections); + + setSelections(newSelections); + setValue(field, newSelectionsKeys, { + shouldDirty: shouldDirty(newSelectionsKeys), + }); + }} + itemToString={() => ""} + > + {({ + getInputProps, + getItemProps, + getMenuProps, + isOpen, + inputValue, + highlightedIndex, + }) => ( +
+ {selections.map((suggestion: DataItem) => ( + + {suggestion.name} + { + const newSelections = selections.filter( + (item: DataItem) => item.key !== suggestion.key + ); + + const newSelectionsKeys = getNewSelectionKeys(newSelections); + + setSelections(newSelections); + setValue(field, newSelectionsKeys, { + shouldDirty: shouldDirty(newSelectionsKeys), + }); + }} + > + Remove suggestion + + + ))} + + + + + + {isOpen && ( +
    + {data + .filter( + (item) => + !inputValue || + item.key.toLowerCase().includes(inputValue) || + item.name.toLowerCase().includes(inputValue) + ) + .map((item, index) => ( +
  • + {item.name} +
  • + ))} +
+ )} +
+ )} +
+ ); +} + +export default SearchAutocomplete; diff --git a/static/js/publisher-pages/components/SearchAutocomplete/index.ts b/static/js/publisher-pages/components/SearchAutocomplete/index.ts new file mode 100644 index 0000000000..365f03ff4b --- /dev/null +++ b/static/js/publisher-pages/components/SearchAutocomplete/index.ts @@ -0,0 +1 @@ +export { default } from "./SearchAutocomplete"; diff --git a/static/js/publisher-pages/components/SectionNav/SectionNav.tsx b/static/js/publisher-pages/components/SectionNav/SectionNav.tsx index fbd8ecfd45..9100043a12 100644 --- a/static/js/publisher-pages/components/SectionNav/SectionNav.tsx +++ b/static/js/publisher-pages/components/SectionNav/SectionNav.tsx @@ -1,3 +1,4 @@ +import { Link } from "react-router-dom"; import { Tabs } from "@canonical/react-components"; type Props = { @@ -13,7 +14,6 @@ function SectionNav({ activeTab, snapName }: Props) { label: "Listing", active: activeTab === "listing" || !activeTab, href: `/${snapName}/listing`, - "data-tour": "listing-intro", }, { label: "Builds", @@ -33,12 +33,14 @@ function SectionNav({ activeTab, snapName }: Props) { { label: "Publicise", active: activeTab === "publicise", - href: `/${snapName}/publicise`, + to: `/${snapName}/publicise`, + component: Link, }, { label: "Settings", active: activeTab === "settings", - href: `/${snapName}/settings`, + to: `/${snapName}/settings`, + component: Link, }, ]} /> diff --git a/static/js/publisher-pages/components/UpdateMetadataModal/UpdateMetadataModal.tsx b/static/js/publisher-pages/components/UpdateMetadataModal/UpdateMetadataModal.tsx new file mode 100644 index 0000000000..1f64e1b142 --- /dev/null +++ b/static/js/publisher-pages/components/UpdateMetadataModal/UpdateMetadataModal.tsx @@ -0,0 +1,53 @@ +import { Modal, Button } from "@canonical/react-components"; + +type Props = { + setShowMetadataWarningModal: Function; + submitForm: Function; + formData: any; +}; + +function UpdateMetadataModal({ + setShowMetadataWarningModal, + submitForm, + formData, +}: Props) { + return ( + { + setShowMetadataWarningModal(false); + }} + title="Warning" + buttonRow={ + <> + + + + } + > +

+ Making these changes means that the snap will no longer use the data + from snapcraft.yaml. +

+
+ ); +} + +export default UpdateMetadataModal; diff --git a/static/js/publisher-pages/components/UpdateMetadataModal/__tests__/UpdateMetadataModal.test.tsx b/static/js/publisher-pages/components/UpdateMetadataModal/__tests__/UpdateMetadataModal.test.tsx new file mode 100644 index 0000000000..707e9ff5c9 --- /dev/null +++ b/static/js/publisher-pages/components/UpdateMetadataModal/__tests__/UpdateMetadataModal.test.tsx @@ -0,0 +1,40 @@ +import { screen, render, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; + +import UpdateMetadataModal from "../UpdateMetadataModal"; + +const setShowMetadataWarningModal = jest.fn(); +const submitForm = jest.fn(); +const formData = {}; + +const renderComponent = () => { + return render( + + ); +}; + +describe("UpdateMetadataModal", () => { + test("cancel button closes modal", async () => { + const user = userEvent.setup(); + renderComponent(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => { + expect(setShowMetadataWarningModal).toHaveBeenCalledWith(false); + }); + }); + + test("save button submits form and closes modal", async () => { + const user = userEvent.setup(); + renderComponent(); + await user.click(screen.getByRole("button", { name: "Save changes" })); + await waitFor(() => { + expect(setShowMetadataWarningModal).toHaveBeenCalledWith(false); + expect(submitForm).toHaveBeenCalledWith(formData); + }); + }); +}); diff --git a/static/js/publisher-pages/components/UpdateMetadataModal/index.ts b/static/js/publisher-pages/components/UpdateMetadataModal/index.ts new file mode 100644 index 0000000000..1685ee13ec --- /dev/null +++ b/static/js/publisher-pages/components/UpdateMetadataModal/index.ts @@ -0,0 +1 @@ +export { default } from "./UpdateMetadataModal"; diff --git a/static/js/publisher-pages/index.tsx b/static/js/publisher-pages/index.tsx index e24a32c7b8..481ed0dfa5 100644 --- a/static/js/publisher-pages/index.tsx +++ b/static/js/publisher-pages/index.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "react-query"; import Root from "./routes/root"; import Publicise from "./pages/Publicise"; +import Settings from "./pages/Settings"; import ValidationSets from "./pages/ValidationSets"; import ValidationSet from "./pages/ValidationSet"; @@ -24,7 +25,10 @@ const router = createBrowserRouter([ path: "/:snapId/publicise/cards", element: , }, - + { + path: "/:snapId/settings", + element: , + }, { path: "/validation-sets", element: , diff --git a/static/js/publisher/settings/components/App/App.tsx b/static/js/publisher-pages/pages/Settings/Settings.tsx similarity index 95% rename from static/js/publisher/settings/components/App/App.tsx rename to static/js/publisher-pages/pages/Settings/Settings.tsx index 53c03788e1..fb75796822 100644 --- a/static/js/publisher/settings/components/App/App.tsx +++ b/static/js/publisher-pages/pages/Settings/Settings.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; import { useForm, useWatch } from "react-hook-form"; +import { useParams } from "react-router-dom"; import { Button, Form, @@ -10,18 +11,19 @@ import { Tooltip, } from "@canonical/react-components"; -import PageHeader from "../../../shared/PageHeader"; -import SaveAndPreview from "../../../shared/SaveAndPreview"; -import SearchAutocomplete from "../../../shared/SearchAutocomplete"; -import UpdateMetadataModal from "../../../shared/UpdateMetadataModal"; -import SaveStateNotifications from "../../../shared/SaveStateNotifications"; -import { UnregisterSnapModal } from "../UnregisterSnapModal"; +import SectionNav from "../../components/SectionNav"; +import SaveAndPreview from "../../components/SaveAndPreview"; +import SearchAutocomplete from "../../components/SearchAutocomplete"; +import UpdateMetadataModal from "../../components/UpdateMetadataModal"; +import SaveStateNotifications from "../../components/SaveStateNotifications"; +import { UnregisterSnapModal } from "./UnregisterSnapModal"; import { getSettingsData, getFormData } from "../../utils"; -function App() { - const settingsData = getSettingsData(window?.settingsData); - const countries = window?.countries; +function Settings() { + const { snapId } = useParams(); + const settingsData = getSettingsData(window.SNAP_SETTINGS_DATA); + const countries = window.SNAP_SETTINGS_DATA.countries; const [isSaving, setIsSaving] = useState(false); const [hasSaved, setHasSaved] = useState(false); @@ -156,12 +158,12 @@ function App() { return ( <> - +

+ My snaps / {snapId} / + Settings +

+ + {settingsData?.visibility_locked && (
@@ -514,4 +516,4 @@ function App() { ); } -export default App; +export default Settings; diff --git a/static/js/publisher/settings/components/UnregisterSnapModal.tsx b/static/js/publisher-pages/pages/Settings/UnregisterSnapModal.tsx similarity index 100% rename from static/js/publisher/settings/components/UnregisterSnapModal.tsx rename to static/js/publisher-pages/pages/Settings/UnregisterSnapModal.tsx diff --git a/static/js/publisher-pages/pages/Settings/__tests__/UnregisterSnapModal.test.tsx b/static/js/publisher-pages/pages/Settings/__tests__/UnregisterSnapModal.test.tsx new file mode 100644 index 0000000000..d2ce535ef6 --- /dev/null +++ b/static/js/publisher-pages/pages/Settings/__tests__/UnregisterSnapModal.test.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import { UnregisterSnapModal } from "../UnregisterSnapModal"; + +// Mock the global fetch function +global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + }) +) as jest.Mock; + +const mockSetUnregisterModalOpen = jest.fn(); +const mockSetUnregisterError = jest.fn(); +const mockSetUnregisterErrorMessage = jest.fn(); + +const defaultProps = { + snapName: "test-snap", + setUnregisterModalOpen: mockSetUnregisterModalOpen, + setUnregisterError: mockSetUnregisterError, + setUnregisterErrorMessage: mockSetUnregisterErrorMessage, +}; + +describe("UnregisterSnapModal", () => { + beforeEach(() => { + (global.fetch as jest.Mock).mockClear(); + mockSetUnregisterModalOpen.mockClear(); + mockSetUnregisterError.mockClear(); + mockSetUnregisterErrorMessage.mockClear(); + }); + + test("renders the modal with the correct snap name", () => { + render(); + expect(screen.getByText('Unregister "test-snap"')).toBeInTheDocument(); + }); + + test("closes the modal when Cancel button is clicked", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText("Cancel")); + expect(mockSetUnregisterModalOpen).toHaveBeenCalledWith(false); + }); + + test("disables the Unregister button and shows spinner when clicked", async () => { + const user = userEvent.setup(); + render(); + const unregisterButton = screen.getByText("Unregister"); + await user.click(unregisterButton); + expect(unregisterButton).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByText("Unregistering...")).toBeInTheDocument(); + }); + + test("calls fetch with correct parameters and redirects on success", async () => { + const user = userEvent.setup(); + Object.defineProperty(window, "location", { + value: { href: "" }, + writable: true, + }); + render(); + await user.click(screen.getByText("Unregister")); + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "/packages/test-snap", + expect.objectContaining({ + method: "DELETE", + headers: { + "X-CSRFToken": window["CSRF_TOKEN"], + }, + }) + ); + expect(window.location.href).toBe("/snaps"); + }); + }); + + test("handles errors correctly", async () => { + const user = userEvent.setup(); + (global.fetch as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ + ok: false, + json: () => Promise.resolve({ error: "Some error occurred" }), + }) + ); + render(); + await user.click(screen.getByText("Unregister")); + await waitFor(() => { + expect(mockSetUnregisterModalOpen).toHaveBeenCalledWith(false); + expect(mockSetUnregisterError).toHaveBeenCalledWith(true); + expect(mockSetUnregisterErrorMessage).toHaveBeenCalledWith( + "Some error occurred" + ); + }); + }); + + test("logs error to console if fetch throws", async () => { + const user = userEvent.setup(); + console.error = jest.fn(); + (global.fetch as jest.Mock).mockImplementationOnce(() => + Promise.reject("Fetch error") + ); + render(); + await user.click(screen.getByText("Unregister")); + await waitFor(() => { + expect(console.error).toHaveBeenCalledWith("Fetch error"); + }); + }); +}); diff --git a/static/js/publisher-pages/pages/Settings/index.ts b/static/js/publisher-pages/pages/Settings/index.ts new file mode 100644 index 0000000000..41d6622394 --- /dev/null +++ b/static/js/publisher-pages/pages/Settings/index.ts @@ -0,0 +1 @@ +export { default } from "./Settings"; diff --git a/static/js/publisher-pages/types/index.d.ts b/static/js/publisher-pages/types/index.d.ts index c9bb6c5038..922a6c6927 100644 --- a/static/js/publisher-pages/types/index.d.ts +++ b/static/js/publisher-pages/types/index.d.ts @@ -12,3 +12,24 @@ export type ValidationSet = { snaps: Snap[]; timestamp: string; }; + +export type SettingsData = { + blacklist_countries: string[]; + blacklist_country_keys: string; + 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[]; + whitelist_country_keys: string; +}; diff --git a/static/js/publisher/settings/utils/getChanges.ts b/static/js/publisher-pages/utils/getChanges.ts similarity index 100% rename from static/js/publisher/settings/utils/getChanges.ts rename to static/js/publisher-pages/utils/getChanges.ts diff --git a/static/js/publisher/settings/utils/getFormData.ts b/static/js/publisher-pages/utils/getFormData.ts similarity index 96% rename from static/js/publisher/settings/utils/getFormData.ts rename to static/js/publisher-pages/utils/getFormData.ts index 2c22e46841..235b563a79 100644 --- a/static/js/publisher/settings/utils/getFormData.ts +++ b/static/js/publisher-pages/utils/getFormData.ts @@ -1,6 +1,6 @@ import getChanges from "./getChanges"; -import type { SettingsData } from "../types/SettingsData"; +import type { SettingsData } from "../types"; function getFormData( settingsData: SettingsData, diff --git a/static/js/publisher/settings/utils/getSettingsData.ts b/static/js/publisher-pages/utils/getSettingsData.ts similarity index 86% rename from static/js/publisher/settings/utils/getSettingsData.ts rename to static/js/publisher-pages/utils/getSettingsData.ts index 2e6245ef71..d1c7f29b84 100644 --- a/static/js/publisher/settings/utils/getSettingsData.ts +++ b/static/js/publisher-pages/utils/getSettingsData.ts @@ -1,4 +1,4 @@ -import type { SettingsData } from "../types/SettingsData"; +import type { SettingsData } from "../types"; function getCountryKeysStatus(settingsData: SettingsData) { if (settingsData?.blacklist_country_keys) { @@ -33,9 +33,8 @@ function getVisibilityStatus(data: SettingsData) { function getSettingsData(settingsData: SettingsData) { settingsData.visibility = getVisibilityStatus(settingsData); - settingsData.territory_distribution_status = getTerritoryDistributionStatus( - settingsData - ); + settingsData.territory_distribution_status = + getTerritoryDistributionStatus(settingsData); settingsData.whitelist_country_keys = settingsData?.whitelist_countries .sort() .join(" "); diff --git a/static/js/publisher/settings/utils/index.ts b/static/js/publisher-pages/utils/index.ts similarity index 100% rename from static/js/publisher/settings/utils/index.ts rename to static/js/publisher-pages/utils/index.ts diff --git a/static/js/publisher/settings/components/App/__tests__/UnregisterSnapModal.test.tsx b/static/js/publisher/settings/components/App/__tests__/UnregisterSnapModal.test.tsx deleted file mode 100644 index 9b7fdc66b9..0000000000 --- a/static/js/publisher/settings/components/App/__tests__/UnregisterSnapModal.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from "@testing-library/user-event"; -import '@testing-library/jest-dom'; -import { UnregisterSnapModal } from '../../UnregisterSnapModal'; - -// Mock the global fetch function -global.fetch = jest.fn(() => Promise.resolve({ - ok: true, - json: () => Promise.resolve({}), -})) as jest.Mock; - -const mockSetUnregisterModalOpen = jest.fn(); -const mockSetUnregisterError = jest.fn(); -const mockSetUnregisterErrorMessage = jest.fn(); - -const defaultProps = { - snapName: 'test-snap', - setUnregisterModalOpen: mockSetUnregisterModalOpen, - setUnregisterError: mockSetUnregisterError, - setUnregisterErrorMessage: mockSetUnregisterErrorMessage, -}; - -describe('UnregisterSnapModal', () => { - beforeEach(() => { - (global.fetch as jest.Mock).mockClear(); - mockSetUnregisterModalOpen.mockClear(); - mockSetUnregisterError.mockClear(); - mockSetUnregisterErrorMessage.mockClear(); - }); - - test('renders the modal with the correct snap name', () => { - render(); - expect(screen.getByText('Unregister "test-snap"')).toBeInTheDocument(); - }); - - test('closes the modal when Cancel button is clicked', async () => { - const user = userEvent.setup(); - render(); - await user.click(screen.getByText('Cancel')); - expect(mockSetUnregisterModalOpen).toHaveBeenCalledWith(false); - }); - - test('disables the Unregister button and shows spinner when clicked', async () => { - const user = userEvent.setup(); - render(); - const unregisterButton = screen.getByText('Unregister'); - await user.click(unregisterButton); - expect(unregisterButton).toHaveAttribute("aria-disabled","true"); - expect(screen.getByText('Unregistering...')).toBeInTheDocument(); - }); - - test('calls fetch with correct parameters and redirects on success', async () => { - const user = userEvent.setup(); - Object.defineProperty(window, 'location', { - value: { href: '' }, - writable: true, - }); - render(); - await user.click(screen.getByText('Unregister')); - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith('/packages/test-snap', expect.objectContaining({ - method: 'DELETE', - headers: { - 'X-CSRFToken': window['CSRF_TOKEN'], - }, - })); - expect(window.location.href).toBe('/snaps'); - }); - }); - - test('handles errors correctly', async () => { - const user = userEvent.setup(); - (global.fetch as jest.Mock).mockImplementationOnce(() => Promise.resolve({ - ok: false, - json: () => Promise.resolve({ error: 'Some error occurred' }), - })); - render(); - await user.click(screen.getByText('Unregister')); - await waitFor(() => { - expect(mockSetUnregisterModalOpen).toHaveBeenCalledWith(false); - expect(mockSetUnregisterError).toHaveBeenCalledWith(true); - expect(mockSetUnregisterErrorMessage).toHaveBeenCalledWith('Some error occurred'); - }); - }); - - test('logs error to console if fetch throws', async () => { - const user = userEvent.setup(); - console.error = jest.fn(); - (global.fetch as jest.Mock).mockImplementationOnce(() => Promise.reject('Fetch error')); - render(); - await user.click(screen.getByText('Unregister')); - await waitFor(() => { - expect(console.error).toHaveBeenCalledWith('Fetch error'); - }); - }); -}); diff --git a/static/js/publisher/settings/components/App/index.ts b/static/js/publisher/settings/components/App/index.ts deleted file mode 100644 index 8ce017e646..0000000000 --- a/static/js/publisher/settings/components/App/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./App"; diff --git a/static/js/publisher/settings/index.tsx b/static/js/publisher/settings/index.tsx deleted file mode 100644 index f74d8125c0..0000000000 --- a/static/js/publisher/settings/index.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { createRoot } from "react-dom/client"; -import * as Sentry from "@sentry/react"; -import { Integrations } from "@sentry/tracing"; -import App from "./components/App"; - -Sentry.init({ - dsn: window.SENTRY_DSN, - integrations: [new Integrations.BrowserTracing()], - tracesSampleRate: 1.0, -}); - -const container = document.getElementById("main-content"); -const root = createRoot(container as HTMLElement); -root.render(); diff --git a/static/js/publisher/settings/types/SettingsData.d.ts b/static/js/publisher/settings/types/SettingsData.d.ts deleted file mode 100644 index f238d788fb..0000000000 --- a/static/js/publisher/settings/types/SettingsData.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -type SettingsData = { - snap_title: string; - publisher_name: string; - snap_name: string; - snap_id: string; - store: string; - status: string; - update_metadata_on_release: boolean; - private: boolean; - unlisted: boolean; - visibility: string; - whitelist_countries: Array; - blacklist_countries: Array; - territory_distribution_status: string; - whitelist_country_keys: string; - blacklist_country_keys: string; - country_keys_status: string | null; - visibility_locked: boolean -}; - -export type { SettingsData }; diff --git a/static/js/publisher/settings/types/index.d.ts b/static/js/publisher/settings/types/index.d.ts deleted file mode 100644 index f62ece5ae4..0000000000 --- a/static/js/publisher/settings/types/index.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Empty export to mark this file as a module. -// This is required to augment global scope. -export {}; - -declare global { - interface Window { - SENTRY_DSN: string; - CSRF_TOKEN: string; - settingsData: any; - countries: Array<{ key: string; name: string }>; - } -} diff --git a/templates/publisher/settings.html b/templates/publisher/settings.html deleted file mode 100644 index edeff1d917..0000000000 --- a/templates/publisher/settings.html +++ /dev/null @@ -1,31 +0,0 @@ -{% extends "publisher/_publisher_layout.html" %} - -{% block meta_title %} -Settings for {% if display_title %}{{ display_title }}{% else %}{{ snap_title }}{% endif %} -{% endblock %} - -{% block content %} -
- - -{% endblock %} - - diff --git a/templates/store/publisher.html b/templates/store/publisher.html index 68c4fd4e28..95b7c4c29a 100644 --- a/templates/store/publisher.html +++ b/templates/store/publisher.html @@ -4,12 +4,30 @@
{% endblock %} diff --git a/tests/publisher/snaps/tests_post_settings.py b/tests/publisher/snaps/tests_post_settings.py index a81cf379e6..3725ee1aa4 100644 --- a/tests/publisher/snaps/tests_post_settings.py +++ b/tests/publisher/snaps/tests_post_settings.py @@ -153,7 +153,7 @@ def test_return_error_update_one_field(self): ) self.assertEqual(response.status_code, 200) - self.assert_template_used("publisher/settings.html") + self.assert_template_used("store/publisher.html") self.assert_context("snap_id", self.snap_id) self.assert_context("snap_title", "test snap") @@ -232,7 +232,7 @@ def test_return_error_udpate_all_field(self): ) self.assertEqual(response.status_code, 200) - self.assert_template_used("publisher/settings.html") + self.assert_template_used("store/publisher.html") # Not updatable fields self.assert_context("snap_id", self.snap_id) @@ -321,6 +321,6 @@ def test_return_error_invalid_field(self): ) self.assertEqual(response.status_code, 200) - self.assert_template_used("publisher/settings.html") + self.assert_template_used("store/publisher.html") self.assert_context("field_errors", {"description": "error message"}) diff --git a/tests/publisher/snaps/tests_settings.py b/tests/publisher/snaps/tests_settings.py index ddac96441b..7149e4826f 100644 --- a/tests/publisher/snaps/tests_settings.py +++ b/tests/publisher/snaps/tests_settings.py @@ -88,7 +88,7 @@ def test_account_logged_in(self): self.check_call_by_api_url(responses.calls) assert response.status_code == 200 - self.assert_template_used("publisher/settings.html") + self.assert_template_used("store/publisher.html") self.assert_context("snap_id", "id") self.assert_context("snap_title", "test snap") diff --git a/webapp/publisher/snaps/settings_views.py b/webapp/publisher/snaps/settings_views.py index 7b1bba629a..59cd8d999c 100644 --- a/webapp/publisher/snaps/settings_views.py +++ b/webapp/publisher/snaps/settings_views.py @@ -77,7 +77,7 @@ def get_settings(snap_name, return_json=False): if return_json: return flask.jsonify(context) - return flask.render_template("publisher/settings.html", **context) + return flask.render_template("store/publisher.html", **context) @login_required @@ -187,6 +187,6 @@ def post_settings(snap_name, return_json=False): if return_json: return flask.jsonify(context) - return flask.render_template("publisher/settings.html", **context) + return flask.render_template("store/publisher.html", **context) return flask.redirect(flask.url_for(".get_settings", snap_name=snap_name)) diff --git a/webpack.config.entry.js b/webpack.config.entry.js index d2a0e3722c..50179b267f 100644 --- a/webpack.config.entry.js +++ b/webpack.config.entry.js @@ -18,7 +18,6 @@ module.exports = { "publisher-details": "./static/js/public/publisher-details.ts", "brand-store": "./static/js/brand-store/brand-store.tsx", "publisher-listing": "./static/js/publisher/listing/index.tsx", - "publisher-settings": "./static/js/publisher/settings/index.tsx", "about-listing": "./static/js/public/about/listing.ts", store: "./static/js/store/index.tsx", "publisher-pages": "./static/js/publisher-pages/index.tsx", From 9c27e8a45d64678604b635afc744c9a9d52f2fb7 Mon Sep 17 00:00:00 2001 From: ilayda-cp Date: Fri, 20 Sep 2024 12:59:35 +0300 Subject: [PATCH 3/5] Wd 14634 metrics page (#4851) * feat: migrate metrics page to react --- .../__tests__/useActiveDeviceMetrics.test.ts | 17 + .../hooks/__tests__/useCountryMetrics.test.ts | 11 + .../__tests__/useMetricsAnnotation.test.ts | 11 + .../hooks/useActiveDeviceMetrics.ts | 40 ++ .../hooks/useCountryMetrics.ts | 27 + .../hooks/useMetricsAnnotation.ts | 22 + static/js/publisher-pages/index.tsx | 5 + .../pages/Metrics/ActiveDeviceAnnotation.tsx | 53 ++ .../Metrics/ActiveDeviceMetricFilter.tsx | 72 ++ .../pages/Metrics/ActiveDeviceMetrics.tsx | 118 +++ .../publisher-pages/pages/Metrics/Metrics.tsx | 58 ++ .../pages/Metrics/TerritoryMetrics.tsx | 84 +++ .../__tests__/ActiveDeviceAnnotation.test.tsx | 110 +++ .../__tests__/ActiveDeviceMetrics.test.tsx | 225 ++++++ .../__tests__/TerritoryMetrics.test.tsx | 118 +++ .../js/publisher-pages/pages/Metrics/index.ts | 1 + .../graphs/activeDevicesGraph/index.ts | 5 - static/js/publisher/metrics/metrics.ts | 74 +- templates/publisher/metrics.html | 127 +--- tests/publisher/snaps/tests_get_metrics.py | 672 +++++++----------- webapp/metrics/helper.py | 24 +- webapp/publisher/snaps/metrics_views.py | 135 ++-- webapp/publisher/snaps/views.py | 15 + webpack.config.entry.js | 1 + 24 files changed, 1399 insertions(+), 626 deletions(-) create mode 100644 static/js/publisher-pages/hooks/__tests__/useActiveDeviceMetrics.test.ts create mode 100644 static/js/publisher-pages/hooks/__tests__/useCountryMetrics.test.ts create mode 100644 static/js/publisher-pages/hooks/__tests__/useMetricsAnnotation.test.ts create mode 100644 static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts create mode 100644 static/js/publisher-pages/hooks/useCountryMetrics.ts create mode 100644 static/js/publisher-pages/hooks/useMetricsAnnotation.ts create mode 100644 static/js/publisher-pages/pages/Metrics/ActiveDeviceAnnotation.tsx create mode 100644 static/js/publisher-pages/pages/Metrics/ActiveDeviceMetricFilter.tsx create mode 100644 static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx create mode 100644 static/js/publisher-pages/pages/Metrics/Metrics.tsx create mode 100644 static/js/publisher-pages/pages/Metrics/TerritoryMetrics.tsx create mode 100644 static/js/publisher-pages/pages/Metrics/__tests__/ActiveDeviceAnnotation.test.tsx create mode 100644 static/js/publisher-pages/pages/Metrics/__tests__/ActiveDeviceMetrics.test.tsx create mode 100644 static/js/publisher-pages/pages/Metrics/__tests__/TerritoryMetrics.test.tsx create mode 100644 static/js/publisher-pages/pages/Metrics/index.ts diff --git a/static/js/publisher-pages/hooks/__tests__/useActiveDeviceMetrics.test.ts b/static/js/publisher-pages/hooks/__tests__/useActiveDeviceMetrics.test.ts new file mode 100644 index 0000000000..0f4a6587ae --- /dev/null +++ b/static/js/publisher-pages/hooks/__tests__/useActiveDeviceMetrics.test.ts @@ -0,0 +1,17 @@ +import * as ReactQuery from "react-query"; +import { renderHook } from "@testing-library/react"; +import useActiveDeviceMetrics from "../useActiveDeviceMetrics"; + +describe("useActiveDeviceMetrics", () => { + test("Calls useQuery", () => { + jest.spyOn(ReactQuery, "useQuery").mockImplementation(jest.fn()); + renderHook(() => + useActiveDeviceMetrics({ + period: "30d", + snapId: "test-id", + type: "version", + }) + ); + expect(ReactQuery.useQuery).toHaveBeenCalled(); + }); +}); diff --git a/static/js/publisher-pages/hooks/__tests__/useCountryMetrics.test.ts b/static/js/publisher-pages/hooks/__tests__/useCountryMetrics.test.ts new file mode 100644 index 0000000000..f120d43381 --- /dev/null +++ b/static/js/publisher-pages/hooks/__tests__/useCountryMetrics.test.ts @@ -0,0 +1,11 @@ +import * as ReactQuery from "react-query"; +import { renderHook } from "@testing-library/react"; +import useCountryMetrics from "../useCountryMetrics"; + +describe("useCountryMetrics", () => { + test("Calls useQuery", () => { + jest.spyOn(ReactQuery, "useQuery").mockImplementation(jest.fn()); + renderHook(() => useCountryMetrics("test-id")); + expect(ReactQuery.useQuery).toHaveBeenCalled(); + }); +}); diff --git a/static/js/publisher-pages/hooks/__tests__/useMetricsAnnotation.test.ts b/static/js/publisher-pages/hooks/__tests__/useMetricsAnnotation.test.ts new file mode 100644 index 0000000000..d2c48ba108 --- /dev/null +++ b/static/js/publisher-pages/hooks/__tests__/useMetricsAnnotation.test.ts @@ -0,0 +1,11 @@ +import * as ReactQuery from "react-query"; +import { renderHook } from "@testing-library/react"; +import useMetricsAnnotation from "../useMetricsAnnotation"; + +describe("useMetricsAnnotation", () => { + test("Calls useQuery", () => { + jest.spyOn(ReactQuery, "useQuery").mockImplementation(jest.fn()); + renderHook(() => useMetricsAnnotation("test-id")); + expect(ReactQuery.useQuery).toHaveBeenCalled(); + }); +}); diff --git a/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts b/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts new file mode 100644 index 0000000000..3bac68434e --- /dev/null +++ b/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts @@ -0,0 +1,40 @@ +import { useQuery } from "react-query"; + +function useActiveDeviceMetrics({ + snapId, + period, + type, +}: { + snapId: string | undefined; + period: string; + type: string; +}) { + return useQuery({ + queryKey: ["activeDeviceMetrics", snapId, period, type], + queryFn: async () => { + const response = await fetch( + `/${snapId}/metrics/active-devices?period=${period}&active-devices=${type}` + ); + + if (!response.ok) { + if (response.status === 404) { + return { + latest_active_devices: 0, + active_devices: { + series: [], + buckets: [], + }, + }; + } else { + throw new Error("Unable to fetch active device metrics"); + } + } + + return await response.json(); + }, + retry: 0, + refetchOnWindowFocus: false, + }); +} + +export default useActiveDeviceMetrics; diff --git a/static/js/publisher-pages/hooks/useCountryMetrics.ts b/static/js/publisher-pages/hooks/useCountryMetrics.ts new file mode 100644 index 0000000000..2449d08863 --- /dev/null +++ b/static/js/publisher-pages/hooks/useCountryMetrics.ts @@ -0,0 +1,27 @@ +import { useQuery } from "react-query"; + +function useCountryMetrics(snapId: string | undefined) { + return useQuery({ + queryKey: ["countryMetrics", snapId], + queryFn: async () => { + const response = await fetch(`/${snapId}/metrics/country-metric`); + + if (!response.ok) { + if (response.status === 404) { + return { + territories_total: 0, + active_devices: {}, + }; + } else { + throw new Error("Unable to fetch country metrics"); + } + } + + return await response.json(); + }, + retry: 0, + refetchOnWindowFocus: false, + }); +} + +export default useCountryMetrics; diff --git a/static/js/publisher-pages/hooks/useMetricsAnnotation.ts b/static/js/publisher-pages/hooks/useMetricsAnnotation.ts new file mode 100644 index 0000000000..3e82ff575d --- /dev/null +++ b/static/js/publisher-pages/hooks/useMetricsAnnotation.ts @@ -0,0 +1,22 @@ +import { useQuery } from "react-query"; + +function useMetricsAnnotation(snapId?: string) { + return useQuery({ + queryKey: ["annotationMetrics", snapId], + queryFn: async () => { + const response = await fetch( + `/${snapId}/metrics/active-device-annotation` + ); + + if (!response.ok) { + throw new Error("Unable to fetch active device annotations"); + } + + const data = await response.json(); + + return data; + }, + }); +} + +export default useMetricsAnnotation; diff --git a/static/js/publisher-pages/index.tsx b/static/js/publisher-pages/index.tsx index 481ed0dfa5..fb21298089 100644 --- a/static/js/publisher-pages/index.tsx +++ b/static/js/publisher-pages/index.tsx @@ -7,6 +7,7 @@ import Publicise from "./pages/Publicise"; import Settings from "./pages/Settings"; import ValidationSets from "./pages/ValidationSets"; import ValidationSet from "./pages/ValidationSet"; +import Metrics from "./pages/Metrics"; const router = createBrowserRouter([ { @@ -37,6 +38,10 @@ const router = createBrowserRouter([ path: "/validation-sets/:validationSetId", element: , }, + { + path: "/:snapId/metrics", + element: , + }, ], }, ]); diff --git a/static/js/publisher-pages/pages/Metrics/ActiveDeviceAnnotation.tsx b/static/js/publisher-pages/pages/Metrics/ActiveDeviceAnnotation.tsx new file mode 100644 index 0000000000..f21169df87 --- /dev/null +++ b/static/js/publisher-pages/pages/Metrics/ActiveDeviceAnnotation.tsx @@ -0,0 +1,53 @@ +import { Row, Col } from "@canonical/react-components"; +import useMetricsAnnotation from "../../hooks/useMetricsAnnotation"; + +interface IActiveDeviceAnnotation { + buckets: string[]; + name: string; + series: Array<{ + date: string; + display_date: string; + display_name: string; + name: string; + values: number[]; + }>; +} + +function ActiveDeviceAnnotation({ snapId }: { snapId?: string }): JSX.Element { + const { data: annotation }: { data: IActiveDeviceAnnotation | undefined } = + useMetricsAnnotation(snapId); + + return ( + + {annotation + ? annotation.series.map((category) => ( + +

+ {category.name == "featured" ? ( + <> + ⭐{" "} + + Featured snap since {category.display_date} + + + ) : ( + <> + 🗂{" "} + + Added to {category.display_name} in{" "} + {category.display_date} + + + )} +

+ + )) + : null} +
+ ); +} + +export default ActiveDeviceAnnotation; diff --git a/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetricFilter.tsx b/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetricFilter.tsx new file mode 100644 index 0000000000..54c6a89c42 --- /dev/null +++ b/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetricFilter.tsx @@ -0,0 +1,72 @@ +import { Col, Select } from "@canonical/react-components"; + +interface IActiveDeviceMetricFilterProps { + isEmpty: boolean; + period: string; + type: string; + onChange: (field: string, value: string) => void; +} + +export const ActiveDeviceMetricFilter = ({ + isEmpty, + onChange, + period, + type, +}: IActiveDeviceMetricFilterProps) => { + return ( + <> + + onChange("active-devices", event.target.value)} + options={[ + { label: "By version", value: "version" }, + { label: "By OS", value: "os" }, + { label: "By channel", value: "channel" }, + { label: "By architecture", value: "architecture" }, + ]} + /> + + + ); +}; diff --git a/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx b/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx new file mode 100644 index 0000000000..9067f18c13 --- /dev/null +++ b/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx @@ -0,0 +1,118 @@ +import { useParams, useSearchParams } from "react-router-dom"; +import { Row, Col, Spinner, CodeSnippet } from "@canonical/react-components"; + +import { useEffect, useState } from "react"; +import { renderActiveDevicesMetrics } from "../../../publisher/metrics/metrics"; +import { select } from "d3-selection"; +import ActiveDeviceAnnotation from "./ActiveDeviceAnnotation"; +import { ActiveDeviceMetricFilter } from "./ActiveDeviceMetricFilter"; +import useActiveDeviceMetrics from "../../hooks/useActiveDeviceMetrics"; + +function ActiveDeviceMetrics({ + isEmpty, + onDataLoad, +}: { + isEmpty: boolean; + onDataLoad: (dataLength: number | undefined) => void; +}): JSX.Element { + const { snapId } = useParams(); + const [searchParams, setSearchParams] = useSearchParams(); + const [latestActiveDevices, setLatestActiveDevices] = useState( + null + ); + + const period = searchParams.get("period") ?? "30d"; + const type = searchParams.get("active-devices") ?? "version"; + const selector = "#activeDevices"; + + const { status, data, isFetching } = useActiveDeviceMetrics({ + snapId, + period, + type, + }); + + useEffect(() => { + if (data) { + const activeDevices = data.latest_active_devices; + activeDevices && + setLatestActiveDevices( + String(activeDevices).replace(/(.)(?=(\d{3})+$)/g, "$1,") + ); + + data.active_devices && + renderActiveDevicesMetrics({ + selector, + metrics: data.active_devices, + type, + }); + onDataLoad(data.active_devices?.buckets?.length); + } + }, [data]); + + const onChange = (key: string, value: string) => { + // clear the chart + const svg = select(`${selector} svg`); + svg.selectAll("*").remove(); + + setSearchParams((searchParams) => { + searchParams.set(key, value); + return searchParams; + }); + }; + + return ( +
+ + +

Weekly active devices

+
+ {latestActiveDevices} +
+ + +
+ + {isFetching ? ( + + ) : ( + <> + + {isEmpty &&
No data found.
} + {status === "error" && ( + An error occurred. Please try again.
, + wrapLines: true, + }, + ]} + /> + )} + + )} + + +
+
+
+ +
+
+ + +
+ +
+ + ); +} + +export default ActiveDeviceMetrics; diff --git a/static/js/publisher-pages/pages/Metrics/Metrics.tsx b/static/js/publisher-pages/pages/Metrics/Metrics.tsx new file mode 100644 index 0000000000..c4ba0a9db6 --- /dev/null +++ b/static/js/publisher-pages/pages/Metrics/Metrics.tsx @@ -0,0 +1,58 @@ +import { useParams, useSearchParams } from "react-router-dom"; +import { Row, Col } from "@canonical/react-components"; + +import SectionNav from "../../components/SectionNav"; +import ActiveDeviceMetrics from "./ActiveDeviceMetrics"; +import { TerritoryMetrics } from "./TerritoryMetrics"; +import { useState } from "react"; + +const EmptyData = () => { + return ( +
+ + +

+ Measure your snap's performance +

+ + +

+ You'll be able to see active devices and territories when people + start using your snap. +

+ +
+
+ ); +}; + +function Metrics(): JSX.Element { + const { snapId } = useParams(); + + const [isActiveDeviceMetricEmpty, setIsActiveDeviceMetricEmpty] = useState< + boolean | null + >(null); + const [isCountryMetricEmpty, setIsCountryMetricEmpty] = useState< + boolean | null + >(null); + const isEmpty = + Boolean(isActiveDeviceMetricEmpty) && Boolean(isCountryMetricEmpty); + + return ( + <> + + {isEmpty && } + + setIsActiveDeviceMetricEmpty(!dataLength)} + /> + setIsCountryMetricEmpty(!dataLength)} + /> + + ); +} + +export default Metrics; diff --git a/static/js/publisher-pages/pages/Metrics/TerritoryMetrics.tsx b/static/js/publisher-pages/pages/Metrics/TerritoryMetrics.tsx new file mode 100644 index 0000000000..2f5b1562d0 --- /dev/null +++ b/static/js/publisher-pages/pages/Metrics/TerritoryMetrics.tsx @@ -0,0 +1,84 @@ +import { useParams, useSearchParams } from "react-router-dom"; +import { + Row, + Col, + Select, + Spinner, + CodeSnippet, +} from "@canonical/react-components"; + +import { useEffect } from "react"; +import { renderTerritoriesMetrics } from "../../../publisher/metrics/metrics"; +import useCountryMetrics from "../../hooks/useCountryMetrics"; + +export const TerritoryMetrics = ({ + isEmpty, + onDataLoad, +}: { + isEmpty: boolean; + onDataLoad: (dataLength: number | undefined) => void; +}): JSX.Element => { + const { snapId } = useParams(); + const { + status, + data: countryInfo, + isFetching, + }: { + status: string; + data: + | { + active_devices: any; + territories_total: number; + } + | undefined; + isFetching: boolean; + } = useCountryMetrics(snapId); + + useEffect(() => { + if (countryInfo) { + countryInfo.active_devices && + renderTerritoriesMetrics({ + selector: "#territories", + metrics: countryInfo.active_devices, + }); + onDataLoad(countryInfo.active_devices?.length); + } + }, [countryInfo]); + + return ( +
+ + +

Territories

+
+ {countryInfo?.territories_total} +
+ + +
+ + {isFetching ? ( + + ) : ( + <> + {isEmpty &&
No data found.
} + {status === "error" && ( + An error occurred. Please try again., + wrapLines: true, + }, + ]} + /> + )} + + )} + + +
+ +
+
+ ); +}; diff --git a/static/js/publisher-pages/pages/Metrics/__tests__/ActiveDeviceAnnotation.test.tsx b/static/js/publisher-pages/pages/Metrics/__tests__/ActiveDeviceAnnotation.test.tsx new file mode 100644 index 0000000000..e1d5960ff7 --- /dev/null +++ b/static/js/publisher-pages/pages/Metrics/__tests__/ActiveDeviceAnnotation.test.tsx @@ -0,0 +1,110 @@ +import { BrowserRouter } from "react-router-dom"; +import { render, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { QueryClient, QueryClientProvider, useQuery } from "react-query"; + +import ActiveDeviceAnnotation from "../ActiveDeviceAnnotation"; + +const queryClient = new QueryClient(); + +const renderComponent = () => { + return render( + + + + + + ); +}; + +const mockMetricsAnnotation = { + buckets: ["2019-02-08", "2024-07-01", "2019-01-24"], + name: "annotations", + series: [ + { + date: "2019-01-24", + display_date: "January 2019", + display_name: "Server and cloud", + name: "server-and-cloud", + values: [0, 0, 1], + }, + { + date: "2019-02-08", + display_date: "February 2019", + display_name: "Development", + name: "development", + values: [1, 0, 0], + }, + { + date: "2024-07-01", + display_date: "July 2024", + display_name: "Featured", + name: "featured", + values: [0, 1, 0], + }, + ], +}; + +jest.mock("react-query", () => ({ + ...jest.requireActual("react-query"), + useQuery: jest.fn(), +})); + +describe("ActiveDeviceAnnotation", () => { + test("renders the information correctly", async () => { + // @ts-ignore + useQuery.mockReturnValue({ + status: "success", + data: mockMetricsAnnotation, + }); + + const { container } = renderComponent(); + + await waitFor(() => { + const serverAndCloudElement = container.querySelector( + '[data-id="category-server-and-cloud"]' + ); + expect(serverAndCloudElement).toBeInTheDocument(); + expect(serverAndCloudElement).toHaveTextContent( + "Added to Server and cloud in January 2019" + ); + + const categoryDevelopmentElement = container.querySelector( + '[data-id="category-development"]' + ); + expect(categoryDevelopmentElement).toBeInTheDocument(); + expect(categoryDevelopmentElement).toHaveTextContent( + "Added to Development in February 2019" + ); + + const categoryFeaturedElement = container.querySelector( + '[data-id="category-featured"]' + ); + expect(categoryFeaturedElement).toBeInTheDocument(); + expect(categoryFeaturedElement).toHaveTextContent( + "Featured snap since July 2024" + ); + }); + }); + + test("renders empty annotations if the data is returned empty", async () => { + // @ts-ignore + useQuery.mockReturnValue({ + status: "success", + data: { + buckets: [], + name: "annotations", + series: [], + }, + }); + + const { container } = renderComponent(); + + await waitFor(() => { + const serverAndCloudElement = container.querySelector( + '[data-id="category-server-and-cloud"]' + ); + expect(serverAndCloudElement).not.toBeInTheDocument(); + }); + }); +}); diff --git a/static/js/publisher-pages/pages/Metrics/__tests__/ActiveDeviceMetrics.test.tsx b/static/js/publisher-pages/pages/Metrics/__tests__/ActiveDeviceMetrics.test.tsx new file mode 100644 index 0000000000..c000b7984e --- /dev/null +++ b/static/js/publisher-pages/pages/Metrics/__tests__/ActiveDeviceMetrics.test.tsx @@ -0,0 +1,225 @@ +import { BrowserRouter, useSearchParams } from "react-router-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { QueryClient, QueryClientProvider, useQuery } from "react-query"; +import * as MetricsRenderMethods from "../../../../publisher/metrics/metrics"; + +import ActiveDeviceMetrics from "../ActiveDeviceMetrics"; + +const queryClient = new QueryClient(); + +const renderComponent = (isEmpty: boolean) => { + const mock = jest.spyOn(MetricsRenderMethods, "renderActiveDevicesMetrics"); + mock.mockImplementation(jest.fn()); + + return render( + + + + + + ); +}; + +const mockActiveDeviceMetrics = { + active_devices: { + buckets: [ + "2024-08-19", + "2024-08-20", + "2024-08-21", + "2024-08-22", + "2024-08-23", + "2024-08-24", + "2024-08-25", + "2024-08-26", + "2024-08-27", + "2024-08-28", + "2024-08-29", + "2024-08-30", + "2024-08-31", + "2024-09-01", + "2024-09-02", + "2024-09-03", + "2024-09-04", + "2024-09-05", + "2024-09-06", + "2024-09-07", + "2024-09-08", + "2024-09-09", + "2024-09-10", + "2024-09-11", + "2024-09-12", + "2024-09-13", + "2024-09-14", + "2024-09-15", + "2024-09-16", + "2024-09-17", + "2024-09-18", + ], + name: "weekly_installed_base_by_version", + series: [ + { + name: "1.0", + values: [ + 9, 9, 8, 8, 9, 8, 7, 7, 7, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 5, 5, 5, 5, + 5, 6, 6, 5, 5, 5, 5, 5, + ], + }, + ], + }, + latest_active_devices: 5, +}; + +jest.mock("react-query", () => ({ + ...jest.requireActual("react-query"), + useQuery: jest.fn(), +})); + +jest.mock("react-router-dom", () => ({ + ...jest.requireActual("react-router-dom"), + useSearchParams: jest.fn(), +})); + +describe("ActiveDeviceMetrics", () => { + beforeEach(() => { + // @ts-ignore + useSearchParams.mockReturnValue([new URLSearchParams()]); + }); + + test("renders the information correctly", async () => { + // @ts-ignore + useQuery.mockImplementation((params) => { + if (params) { + if (params.queryKey[0] === "activeDeviceMetrics") { + return { + status: "success", + data: mockActiveDeviceMetrics, + }; + } else { + return { + status: "success", + data: { + buckets: [], + name: "annotations", + series: [], + }, + }; + } + } + return { + status: "success", + data: {}, + }; + }); + + renderComponent(false); + + await waitFor(() => { + expect(screen.getByText("Weekly active devices")).toBeInTheDocument(); + expect(screen.getByText("5")).toBeInTheDocument(); + expect(screen.getByText("Past 30 days")).toBeInTheDocument(); + expect(screen.getByText("By version")).toBeInTheDocument(); + }); + }); + + test("renders the error state", async () => { + // @ts-ignore + useQuery.mockImplementation((params) => { + if (params) { + if (params.queryKey[0] === "activeDeviceMetrics") { + return { + status: "error", + data: undefined, + }; + } else { + return { + status: "success", + data: { + buckets: [], + name: "annotations", + series: [], + }, + }; + } + } + return { + status: "success", + data: {}, + }; + }); + + renderComponent(false); + + await waitFor(() => { + expect( + screen.getByText("An error occurred. Please try again.") + ).toBeInTheDocument(); + }); + }); + + test("renders the loading state", async () => { + // @ts-ignore + useQuery.mockImplementation((params) => { + if (params) { + if (params.queryKey[0] === "activeDeviceMetrics") { + return { + isFetching: true, + data: undefined, + }; + } else { + return { + status: "success", + data: { + buckets: [], + name: "annotations", + series: [], + }, + }; + } + } + return { + status: "success", + data: {}, + }; + }); + + renderComponent(false); + + await waitFor(() => { + expect(screen.getByText("Loading")).toBeInTheDocument(); + }); + }); + + test("renders the empty state", async () => { + // @ts-ignore + useQuery.mockImplementation((params) => { + if (params) { + if (params.queryKey[0] === "activeDeviceMetrics") { + return { + status: "success", + data: undefined, + }; + } else { + return { + status: "success", + data: { + buckets: [], + name: "annotations", + series: [], + }, + }; + } + } + return { + status: "success", + data: {}, + }; + }); + + renderComponent(true); + + await waitFor(() => { + expect(screen.getByText("No data found.")).toBeInTheDocument(); + }); + }); +}); diff --git a/static/js/publisher-pages/pages/Metrics/__tests__/TerritoryMetrics.test.tsx b/static/js/publisher-pages/pages/Metrics/__tests__/TerritoryMetrics.test.tsx new file mode 100644 index 0000000000..466f4231ed --- /dev/null +++ b/static/js/publisher-pages/pages/Metrics/__tests__/TerritoryMetrics.test.tsx @@ -0,0 +1,118 @@ +import { BrowserRouter, useSearchParams } from "react-router-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { QueryClient, QueryClientProvider, useQuery } from "react-query"; +import * as MetricsRenderMethods from "../../../../publisher/metrics/metrics"; + +import { TerritoryMetrics } from "../TerritoryMetrics"; + +const queryClient = new QueryClient(); + +const renderComponent = (isEmpty: boolean) => { + const mock = jest.spyOn(MetricsRenderMethods, "renderTerritoriesMetrics"); + mock.mockImplementation(jest.fn()); + + return render( + + + + + + ); +}; + +const mockTerritoryMetrics = { + active_devices: { + "528": { + code: "NL", + color_rgb: [8, 48, 107], + name: "Netherlands", + number_of_users: 1, + percentage_of_users: 1, + }, + "826": { + code: "GB", + color_rgb: [8, 48, 107], + name: "United Kingdom", + number_of_users: 4, + percentage_of_users: 4, + }, + }, + territories_total: 5, +}; + +jest.mock("react-query", () => ({ + ...jest.requireActual("react-query"), + useQuery: jest.fn(), +})); + +jest.mock("react-router-dom", () => ({ + ...jest.requireActual("react-router-dom"), + useSearchParams: jest.fn(), +})); + +describe("ActiveDeviceMetrics", () => { + beforeEach(() => { + // @ts-ignore + useSearchParams.mockReturnValue([new URLSearchParams()]); + }); + + test("renders the information correctly", async () => { + // @ts-ignore + useQuery.mockImplementation(() => ({ + status: "success", + data: mockTerritoryMetrics, + })); + + renderComponent(false); + + await waitFor(() => { + expect(screen.getByText("Territories")).toBeInTheDocument(); + expect(screen.getByText("5")).toBeInTheDocument(); + }); + }); + + test("renders the error state", async () => { + // @ts-ignore + useQuery.mockImplementation(() => ({ + status: "error", + data: undefined, + })); + + renderComponent(false); + + await waitFor(() => { + expect( + screen.getByText("An error occurred. Please try again.") + ).toBeInTheDocument(); + }); + }); + + test("renders the loading state", async () => { + // @ts-ignore + useQuery.mockImplementation(() => ({ + isFetching: true, + data: undefined, + })); + + renderComponent(false); + + await waitFor(() => { + expect(screen.getByText("Loading")).toBeInTheDocument(); + }); + }); + + test("renders the empty state", async () => { + // @ts-ignore + useQuery.mockImplementation(() => ({ + status: "success", + data: undefined, + })); + + renderComponent(true); + + await waitFor(() => { + expect(screen.getByText("No data found.")).toBeInTheDocument(); + }); + }); +}); diff --git a/static/js/publisher-pages/pages/Metrics/index.ts b/static/js/publisher-pages/pages/Metrics/index.ts new file mode 100644 index 0000000000..36f909fdc2 --- /dev/null +++ b/static/js/publisher-pages/pages/Metrics/index.ts @@ -0,0 +1 @@ +export { default } from "./Metrics"; diff --git a/static/js/publisher/metrics/graphs/activeDevicesGraph/index.ts b/static/js/publisher/metrics/graphs/activeDevicesGraph/index.ts index 20d0315f9f..a2091bee4d 100644 --- a/static/js/publisher/metrics/graphs/activeDevicesGraph/index.ts +++ b/static/js/publisher/metrics/graphs/activeDevicesGraph/index.ts @@ -57,7 +57,6 @@ class ActiveDevicesGraph { this.g = undefined; this.transformedData = undefined; - this.annotationsData = undefined; this.data = undefined; this.keys = undefined; this.maxYValue = undefined; @@ -138,10 +137,6 @@ class ActiveDevicesGraph { prepareScales.call(this); - if (this.options.annotations) { - prepareAnnotationsData.call(this); - } - prepareAxis.call(this); return this; diff --git a/static/js/publisher/metrics/metrics.ts b/static/js/publisher/metrics/metrics.ts index 8b0a17c81e..7174beac8f 100644 --- a/static/js/publisher/metrics/metrics.ts +++ b/static/js/publisher/metrics/metrics.ts @@ -7,45 +7,38 @@ type Series = { values: Array; }; -type Metrics = { - activeDevices: { - annotations: { - buckets: Array; - name: string; - series: Array; - }; - metrics: { - buckets: Array; - series: Array; - }; - selector: string; - type: string; +type ActiveDeviceMetric = { + metrics: { + buckets: Array; + series: Array; }; - defaultTrack: string; - territories: { - metrics: { - [key: string]: { - code: string; - color_rgb: string; - name: string; - number_of_users: number; - percentage_of_users: number; - }; + selector: string; + type: string; +}; + +type TerritoriesMetric = { + metrics: { + [key: string]: { + code: string; + color_rgb: string; + name: string; + number_of_users: number; + percentage_of_users: number; }; - selector: string; }; + selector: string; }; -function renderMetrics(metrics: Metrics) { +function renderActiveDevicesMetrics(metrics: ActiveDeviceMetric) { let activeDevices: { series: Array; buckets: Array; } = { series: [], - buckets: metrics.activeDevices.metrics.buckets, + buckets: metrics.metrics.buckets, }; - metrics.activeDevices.metrics.series.forEach((series) => { + metrics.metrics.series.forEach((series) => { let fullSeries = series.values.map((value) => { return value === null ? 0 : value; }); @@ -55,17 +48,11 @@ function renderMetrics(metrics: Metrics) { }); }); - const graph = new ActiveDevicesGraph( - metrics.activeDevices.selector, - activeDevices, - { - stacked: true, - area: true, - graphType: metrics.activeDevices.type, - defaultTrack: metrics.defaultTrack, - annotations: metrics.activeDevices.annotations, - } - ) + const graph = new ActiveDevicesGraph(metrics.selector, activeDevices, { + stacked: true, + area: true, + graphType: metrics.type, + }) .render() // @ts-ignore .enableTooltip() @@ -96,9 +83,10 @@ function renderMetrics(metrics: Metrics) { } }); } +} - // Territories - territoriesMetrics(metrics.territories.selector, metrics.territories.metrics); +function renderTerritoriesMetrics(metrics: TerritoriesMetric) { + territoriesMetrics(metrics.selector, metrics.metrics); } /** @@ -266,4 +254,8 @@ function renderPublisherMetrics(options: { getChunk(chunkedSnaps); } -export { renderMetrics, renderPublisherMetrics }; +export { + renderActiveDevicesMetrics, + renderPublisherMetrics, + renderTerritoriesMetrics, +}; diff --git a/templates/publisher/metrics.html b/templates/publisher/metrics.html index d9d4bd37ec..355ac97773 100644 --- a/templates/publisher/metrics.html +++ b/templates/publisher/metrics.html @@ -1,123 +1,8 @@ -{% extends "publisher/_publisher_layout.html" %} - -{% block meta_title %} -Publisher metrics for {{ snap_title }} -{% endblock %} - +{% set show_header = False %} +{% extends "_base-layout.html" %} {% block content %} -
- {% set selected_tab='metrics' %} - {% include "publisher/_header.html" %} - - {% if nodata %} -
-
-
-

Measure your snap's performance

-
-
-

You'll be able to see active devices and territories when people start using your snap.

-
-
-
- {% endif %} -
-
-
-

Weekly active devices

-
- {{ format_number(latest_active_devices) }} -
-
-
-
-
-
- -
-
- -
-
-
-
- -
-
- {% for category in active_devices_annotations.series %} -
-

- {% if category.name == "featured" %} - ⭐ Featured snap since - {% else %} - 🗂 Added to {{ category.display_name }} in {% endif %}{{ category.display_date }} - -

- {% endfor %} -
-
-
-
-
-
-
-
-

Territories

-
- {{ territories_total }} -
-
-
-
-
-
-
-
-
-
-
-
-{% endblock %} - -{% block scripts_includes %} - -{% endblock %} +
+
-{% block scripts %} - -{% endblock %} + +{% endblock %} \ No newline at end of file diff --git a/tests/publisher/snaps/tests_get_metrics.py b/tests/publisher/snaps/tests_get_metrics.py index f04ac65012..9831502a54 100644 --- a/tests/publisher/snaps/tests_get_metrics.py +++ b/tests/publisher/snaps/tests_get_metrics.py @@ -1,5 +1,8 @@ import random from datetime import datetime +from flask_testing import TestCase +from webapp.app import create_app +from unittest.mock import patch import responses from tests.publisher.endpoint_testing import BaseTestCases @@ -13,14 +16,15 @@ def setUp(self): super().setUp(snap_name=snap_name, endpoint_url=endpoint_url) -class GetMetricsGetInfoPage(BaseTestCases.EndpointLoggedInErrorHandling): +class GetActiveDeviceAnnotationGetInfo( + BaseTestCases.EndpointLoggedInErrorHandling +): def setUp(self): snap_name = "test-snap" api_url = "https://dashboard.snapcraft.io/dev/api/snaps/info/{}" api_url = api_url.format(snap_name) - endpoint_url = "/{}/metrics".format(snap_name) - + endpoint_url = "/{}/metrics/active-device-annotation".format(snap_name) super().setUp( snap_name=snap_name, endpoint_url=endpoint_url, @@ -30,500 +34,370 @@ def setUp(self): ) -class GetMetricsPostMetrics(BaseTestCases.EndpointLoggedInErrorHandling): - def setUp(self): - snap_name = "test-snap" - - self.snap_id = "complexId" - info_url = "https://dashboard.snapcraft.io/dev/api/snaps/info/{}" - self.info_url = info_url.format(snap_name) - - payload = { - "snap_id": "id", - "title": "Test Snap", - "private": False, - "categories": { - "items": [{"name": "test", "since": "2018-01-01T00:00:00"}] - }, - "publisher": {"display-name": "test"}, - } - - responses.add(responses.GET, self.info_url, json=payload, status=200) - - api_url = "https://dashboard.snapcraft.io/dev/api/snaps/metrics" - endpoint_url = "/{}/metrics".format(snap_name) - - super().setUp( - snap_name=snap_name, - endpoint_url=endpoint_url, - api_url=api_url, - method_endpoint="GET", - method_api="POST", - ) - - @responses.activate - def test_no_data(self): - payload = { - "metrics": [ - { - "status": "NO DATA", - "series": [], - "buckets": [], - "metric_name": "weekly_installed_base_by_version", - }, - { - "status": "NO DATA", - "series": [], - "buckets": [], - "metric_name": "weekly_installed_base_by_country", - }, - ] - } - - responses.add(responses.POST, self.api_url, json=payload, status=200) - - response = self.client.get(self.endpoint_url) +class GetActiveDeviceMetrics(TestCase): + render_templates = False - self.assertEqual(3, len(responses.calls)) - called = responses.calls[0] - self.assertEqual(self.info_url, called.request.url) - self.assertEqual( - self.authorization, called.request.headers.get("Authorization") - ) - called = responses.calls[1] - self.assertEqual(self.api_url, called.request.url) - self.assertEqual( - self.authorization, called.request.headers.get("Authorization") - ) + snap_name = "test-snap" + endpoint_url = "/test-snap/metrics/active-devices" - self.assertEqual(response.status_code, 200) - self.assert_template_used("publisher/metrics.html") - self.assert_context("snap_name", self.snap_name) - self.assert_context("snap_title", "Test Snap") - self.assert_context("metric_period", "30d") - self.assert_context("active_device_metric", "version") - self.assert_context("nodata", True) + def create_app(self): + app = create_app(testing=True) + app.secret_key = "secret_key" + app.config["WTF_CSRF_METHODS"] = [] + return app @responses.activate - def test_data_version_1_year(self): + @patch("webapp.publisher.snaps.views.authentication.is_authenticated") + @patch( + "canonicalwebteam.store_api.stores." + "snapstore.SnapStore.get_item_details" + ) + @patch( + "canonicalwebteam.store_api.stores." + "snapstore.SnapPublisher.get_publisher_metrics" + ) + def test_get_active_devices_weekly_installed_by_version( + self, + mock_get_publisher_metrics, + mock_get_item_details, + mock_is_authenticated, + ): + mock_is_authenticated.return_value = True + + mock_get_item_details.return_value = {"snap-id": "id"} random_values = random.sample(range(1, 30), 29) dates = [ datetime(2018, 3, day).strftime("%Y-%m-%d") for day in range(1, 30) ] - countries = [ - {"values": [2], "name": "FR"}, - {"values": [3], "name": "GB"}, - ] - payload = { + + mock_get_publisher_metrics.return_value = { "metrics": [ { - "status": "OK", - "series": [{"values": random_values, "name": "0.1"}], "buckets": dates, "metric_name": "weekly_installed_base_by_version", - }, - { + "series": [{"name": "1.0", "values": random_values}], + "snap_id": "test-id", "status": "OK", - "series": countries, - "buckets": ["2018-03-18"], - "metric_name": "weekly_installed_base_by_country", - }, + } ] } - responses.add(responses.POST, self.api_url, json=payload, status=200) - response = self.client.get(self.endpoint_url + "?period=1y") + response = self.client.get(self.endpoint_url) + self.assertEqual(response.status_code, 200) + response_json = response.json - self.assertEqual(3, len(responses.calls)) - called = responses.calls[0] - self.assertEqual(self.info_url, called.request.url) + self.assertIn("active_devices", response_json) + self.assertIn("latest_active_devices", response_json) self.assertEqual( - self.authorization, called.request.headers.get("Authorization") + response_json["latest_active_devices"], random_values[28] ) - called = responses.calls[1] - self.assertEqual(self.api_url, called.request.url) + + active_devices = response_json["active_devices"] self.assertEqual( - self.authorization, called.request.headers.get("Authorization") + active_devices["name"], "weekly_installed_base_by_version" ) - - self.assertEqual(response.status_code, 200) - self.assert_template_used("publisher/metrics.html") - self.assert_context("snap_name", self.snap_name) - self.assert_context("snap_title", "Test Snap") - self.assert_context("metric_period", "1y") - self.assert_context("active_device_metric", "version") - self.assert_context("nodata", False) + self.assertEqual(active_devices["series"][0]["name"], "1.0") + self.assertEqual(active_devices["series"][0]["values"], random_values) @responses.activate - def test_data_version_1_month(self): + @patch("webapp.publisher.snaps.views.authentication.is_authenticated") + @patch( + "canonicalwebteam.store_api.stores." + "snapstore.SnapStore.get_item_details" + ) + @patch( + "canonicalwebteam.store_api.stores." + "snapstore.SnapPublisher.get_publisher_metrics" + ) + def test_get_active_devices_weekly_installed_by_channel( + self, + mock_get_publisher_metrics, + mock_get_item_details, + mock_is_authenticated, + ): + mock_is_authenticated.return_value = True + mock_get_item_details.return_value = {"snap-id": "id"} random_values = random.sample(range(1, 30), 29) dates = [ datetime(2018, 3, day).strftime("%Y-%m-%d") for day in range(1, 30) ] - countries = [ - {"values": [2], "name": "FR"}, - {"values": [3], "name": "GB"}, - ] - payload = { + mock_get_publisher_metrics.return_value = { "metrics": [ { - "status": "OK", - "series": [{"values": random_values, "name": "0.1"}], "buckets": dates, - "metric_name": "weekly_installed_base_by_version", - }, - { + "metric_name": "weekly_installed_base_by_channel", + "series": [{"name": "1.0", "values": random_values}], + "snap_id": "test-id", "status": "OK", - "series": countries, - "buckets": ["2018-03-18"], - "metric_name": "weekly_installed_base_by_country", - }, + } ] } - responses.add(responses.POST, self.api_url, json=payload, status=200) - response = self.client.get(self.endpoint_url + "?period=30d") - - self.assertEqual(3, len(responses.calls)) - called = responses.calls[0] - self.assertEqual(self.info_url, called.request.url) - self.assertEqual( - self.authorization, called.request.headers.get("Authorization") - ) - called = responses.calls[1] - self.assertEqual(self.api_url, called.request.url) - self.assertEqual( - self.authorization, called.request.headers.get("Authorization") + response = self.client.get( + self.endpoint_url + "?active-devices=channel" ) - self.assertEqual(response.status_code, 200) - self.assert_template_used("publisher/metrics.html") - self.assert_context("snap_name", self.snap_name) - self.assert_context("snap_title", "Test Snap") - self.assert_context("metric_period", "30d") - self.assert_context("active_device_metric", "version") - self.assert_context("nodata", False) - - @responses.activate - def test_data_version_weekly(self): - random_values = random.sample(range(1, 30), 6) - dates = [ - datetime(2018, 3, day).strftime("%Y-%m-%d") for day in range(1, 7) - ] - countries = [ - {"values": [2], "name": "FR"}, - {"values": [3], "name": "GB"}, - ] - payload = { - "metrics": [ - { - "status": "OK", - "series": [{"values": random_values, "name": "0.1"}], - "buckets": dates, - "metric_name": "weekly_installed_base_by_version", - }, - { - "status": "OK", - "series": countries, - "buckets": ["2018-03-18"], - "metric_name": "weekly_installed_base_by_country", - }, - ] - } - responses.add(responses.POST, self.api_url, json=payload, status=200) - - response = self.client.get(self.endpoint_url + "?period=7d") - - self.assertEqual(3, len(responses.calls)) - called = responses.calls[0] - self.assertEqual(self.info_url, called.request.url) + response_json = response.json + self.assertIn("active_devices", response_json) + self.assertIn("latest_active_devices", response_json) self.assertEqual( - self.authorization, called.request.headers.get("Authorization") + response_json["latest_active_devices"], random_values[28] ) - called = responses.calls[1] - self.assertEqual(self.api_url, called.request.url) - self.assertEqual( - self.authorization, called.request.headers.get("Authorization") - ) - - self.assertEqual(response.status_code, 200) - self.assert_template_used("publisher/metrics.html") - self.assert_context("snap_name", self.snap_name) - self.assert_context("snap_title", "Test Snap") - self.assert_context("metric_period", "7d") - self.assert_context("active_device_metric", "version") - self.assert_context("nodata", False) - - @responses.activate - def test_data_version_3_month(self): - random_values = random.sample(range(1, 100), 59) - dates = [] - for month in range(4, 7): - dates = dates + [ - datetime(2018, month, day).strftime("%Y-%m-%d") - for day in range(1, 30) - ] - - countries = [ - {"values": [2], "name": "FR"}, - {"values": [3], "name": "GB"}, - ] - payload = { - "metrics": [ - { - "status": "OK", - "series": [{"values": random_values, "name": "0.1"}], - "buckets": dates, - "metric_name": "weekly_installed_base_by_version", - }, - { - "status": "OK", - "series": countries, - "buckets": ["2018-03-18"], - "metric_name": "weekly_installed_base_by_country", - }, - ] - } - responses.add(responses.POST, self.api_url, json=payload, status=200) - response = self.client.get(self.endpoint_url + "?period=3m") - - self.assertEqual(3, len(responses.calls)) - called = responses.calls[0] - self.assertEqual(self.info_url, called.request.url) - self.assertEqual( - self.authorization, called.request.headers.get("Authorization") - ) - called = responses.calls[1] - self.assertEqual(self.api_url, called.request.url) + active_devices = response_json["active_devices"] self.assertEqual( - self.authorization, called.request.headers.get("Authorization") + active_devices["name"], "weekly_installed_base_by_channel" ) - - self.assertEqual(response.status_code, 200) - self.assert_template_used("publisher/metrics.html") - self.assert_context("snap_name", self.snap_name) - self.assert_context("snap_title", "Test Snap") - self.assert_context("metric_period", "3m") - self.assert_context("active_device_metric", "version") - self.assert_context("nodata", False) + self.assertEqual(active_devices["series"][0]["name"], "latest/1.0") + self.assertEqual(active_devices["series"][0]["values"], random_values) @responses.activate - def test_data_os_7_days(self): - random_values = random.sample(range(1, 100), 59) + @patch("webapp.publisher.snaps.views.authentication.is_authenticated") + @patch( + "canonicalwebteam.store_api.stores." + "snapstore.SnapStore.get_item_details" + ) + @patch( + "canonicalwebteam.store_api.stores." + "snapstore.SnapPublisher.get_publisher_metrics" + ) + def test_get_active_devices_weekly_installed_by_os( + self, + mock_get_publisher_metrics, + mock_get_item_details, + mock_is_authenticated, + ): + mock_is_authenticated.return_value = True + mock_get_item_details.return_value = {"snap-id": "id"} + random_values = random.sample(range(1, 30), 29) dates = [ - datetime(2018, 3, day).strftime("%Y-%m-%d") for day in range(1, 7) - ] - countries = [ - {"values": [2], "name": "FR"}, - {"values": [3], "name": "GB"}, + datetime(2018, 3, day).strftime("%Y-%m-%d") for day in range(1, 30) ] - payload = { + mock_get_publisher_metrics.return_value = { "metrics": [ { - "status": "OK", + "buckets": dates, + "metric_name": "weekly_installed_base_by_operating_system", "series": [ {"values": random_values, "name": "ubuntu/0.1"} ], - "buckets": dates, - "metric_name": "weekly_installed_base_by_operating_system", - }, - { + "snap_id": "test-id", "status": "OK", - "series": countries, - "buckets": ["2018-03-18"], - "metric_name": "weekly_installed_base_by_country", - }, + } ] } - responses.add(responses.POST, self.api_url, json=payload, status=200) - response = self.client.get( - self.endpoint_url + "?period=7d&active-devices=os" - ) + response = self.client.get(self.endpoint_url + "?active-devices=os") + self.assertEqual(response.status_code, 200) + response_json = response.json + self.assertIn("active_devices", response_json) + self.assertIn("latest_active_devices", response_json) - self.assertEqual(3, len(responses.calls)) - called = responses.calls[0] - self.assertEqual(self.info_url, called.request.url) - self.assertEqual( - self.authorization, called.request.headers.get("Authorization") - ) - called = responses.calls[1] - self.assertEqual(self.api_url, called.request.url) + active_devices = response_json["active_devices"] self.assertEqual( - self.authorization, called.request.headers.get("Authorization") + active_devices["name"], "weekly_installed_base_by_operating_system" ) - - self.assertEqual(response.status_code, 200) - self.assert_template_used("publisher/metrics.html") - self.assert_context("snap_name", self.snap_name) - self.assert_context("snap_title", "Test Snap") - self.assert_context("metric_period", "7d") - self.assert_context("active_device_metric", "os") - self.assert_context("nodata", False) + self.assertEqual(active_devices["series"][0]["name"], "Ubuntu 0.1") + self.assertEqual(active_devices["series"][0]["values"], random_values) @responses.activate - def test_data_os_1_year(self): - random_values = random.sample(range(1, 100), 59) + @patch("webapp.publisher.snaps.views.authentication.is_authenticated") + @patch( + "canonicalwebteam.store_api.stores." + "snapstore.SnapStore.get_item_details" + ) + @patch( + "canonicalwebteam.store_api.stores." + "snapstore.SnapPublisher.get_publisher_metrics" + ) + def test_get_active_devices_in_3_months_period( + self, + mock_get_publisher_metrics, + mock_get_item_details, + mock_is_authenticated, + ): + mock_is_authenticated.return_value = True + mock_get_item_details.return_value = {"snap-id": "id"} + random_values = random.sample(range(1, 30), 29) dates = [ datetime(2018, 3, day).strftime("%Y-%m-%d") for day in range(1, 30) ] - countries = [ - {"values": [2], "name": "FR"}, - {"values": [3], "name": "GB"}, - ] - payload = { + mock_get_publisher_metrics.return_value = { "metrics": [ { - "status": "OK", - "series": [ - {"values": random_values, "name": "ubuntu/0.1"} - ], "buckets": dates, - "metric_name": "weekly_installed_base_by_operating_system", - }, - { + "metric_name": "weekly_installed_base_by_architecture", + "series": [{"values": random_values, "name": "0.1"}], + "snap_id": "test-id", "status": "OK", - "series": countries, - "buckets": ["2018-03-18"], - "metric_name": "weekly_installed_base_by_country", - }, + } ] } - responses.add(responses.POST, self.api_url, json=payload, status=200) response = self.client.get( - self.endpoint_url + "?period=1y&active-devices=os" + self.endpoint_url + "?active-devices=architecture&period=3m" ) + self.assertEqual(response.status_code, 200) + response_json = response.json - self.assertEqual(3, len(responses.calls)) - called = responses.calls[0] - self.assertEqual(self.info_url, called.request.url) - self.assertEqual( - self.authorization, called.request.headers.get("Authorization") - ) - called = responses.calls[1] - self.assertEqual(self.api_url, called.request.url) + self.assertIn("active_devices", response_json) + self.assertIn("latest_active_devices", response_json) + + active_devices = response_json["active_devices"] self.assertEqual( - self.authorization, called.request.headers.get("Authorization") + active_devices["name"], "weekly_installed_base_by_architecture" ) + self.assertEqual(active_devices["series"][0]["name"], "0.1") + self.assertEqual(active_devices["series"][0]["values"], random_values) - self.assertEqual(response.status_code, 200) - self.assert_template_used("publisher/metrics.html") - self.assert_context("snap_name", self.snap_name) - self.assert_context("snap_title", "Test Snap") - self.assert_context("metric_period", "1y") - self.assert_context("active_device_metric", "os") - self.assert_context("nodata", False) - @responses.activate - def test_data_os_1_month(self): - random_values = random.sample(range(1, 100), 59) - dates = [ - datetime(2018, 3, day).strftime("%Y-%m-%d") for day in range(1, 30) - ] - countries = [ - {"values": [2], "name": "FR"}, - {"values": [3], "name": "GB"}, - ] - payload = { - "metrics": [ +class GetMetricAnnotation(TestCase): + render_templates = False + + snap_name = "test-snap" + snap_payload = { + "snap_name": snap_name, + "snap_id": "snap-id", + "categories": { + "locked": False, + "items": [ { - "status": "OK", - "series": [ - {"values": random_values, "name": "ubuntu/0.1"} - ], - "buckets": dates, - "metric_name": "weekly_installed_base_by_operating_system", + "featured": False, + "name": "development", + "since": "2019-02-08T17:02:33.318798", }, { - "status": "OK", - "series": countries, - "buckets": ["2018-03-18"], - "metric_name": "weekly_installed_base_by_country", + "featured": True, + "name": "featured", + "since": "2024-07-01T19:45:19.386538", }, - ] - } - responses.add(responses.POST, self.api_url, json=payload, status=200) + { + "featured": True, + "name": "server-and-cloud", + "since": "2019-01-24T10:26:40.642290", + }, + ], + }, + } + endpoint_url = "/test-snap/metrics/active-device-annotation" - response = self.client.get( - self.endpoint_url + "?period=30d&active-devices=os" - ) + def create_app(self): + app = create_app(testing=True) + app.secret_key = "secret_key" + app.config["WTF_CSRF_METHODS"] = [] + return app + + @responses.activate + @patch("webapp.publisher.snaps.views.authentication.is_authenticated") + @patch( + "canonicalwebteam.store_api.stores." + "snapstore.SnapPublisher.get_snap_info" + ) + def test_get_active_devices_weekly_installed_by_version( + self, mock_get_snap_info, mock_is_authenticated + ): + mock_is_authenticated.return_value = True + + mock_get_snap_info.return_value = self.snap_payload + + response = self.client.get(self.endpoint_url) + self.assertEqual(response.status_code, 200) + response_json = response.json - self.assertEqual(3, len(responses.calls)) - called = responses.calls[0] - self.assertEqual(self.info_url, called.request.url) self.assertEqual( - self.authorization, called.request.headers.get("Authorization") + response_json["buckets"], + ["2019-02-08", "2024-07-01", "2019-01-24"], ) - called = responses.calls[1] - self.assertEqual(self.api_url, called.request.url) + self.assertEqual(response_json["name"], "annotations") self.assertEqual( - self.authorization, called.request.headers.get("Authorization") + response_json["series"], + [ + { + "date": "2019-01-24", + "display_date": "January 2019", + "display_name": "Server and cloud", + "name": "server-and-cloud", + "values": [0, 0, 1], + }, + { + "date": "2019-02-08", + "display_date": "February 2019", + "display_name": "Development", + "name": "development", + "values": [1, 0, 0], + }, + { + "date": "2024-07-01", + "display_date": "July 2024", + "display_name": "Featured", + "name": "featured", + "values": [0, 1, 0], + }, + ], ) - self.assertEqual(response.status_code, 200) - self.assert_template_used("publisher/metrics.html") - self.assert_context("snap_name", self.snap_name) - self.assert_context("snap_title", "Test Snap") - self.assert_context("metric_period", "30d") - self.assert_context("active_device_metric", "os") - self.assert_context("nodata", False) + +class GetCountryMetric(TestCase): + render_templates = False + + snap_name = "test-snap" + endpoint_url = "/test-snap/metrics/country-metric" + + def create_app(self): + app = create_app(testing=True) + app.secret_key = "secret_key" + app.config["WTF_CSRF_METHODS"] = [] + return app @responses.activate - def test_data_os_3_month(self): - random_values = random.sample(range(1, 100), 59) - dates = [] - for month in range(4, 7): - dates = dates + [ - datetime(2018, month, day).strftime("%Y-%m-%d") - for day in range(1, 30) - ] + @patch("webapp.publisher.snaps.views.authentication.is_authenticated") + @patch( + "canonicalwebteam.store_api.stores." + "snapstore.SnapStore.get_item_details" + ) + @patch( + "canonicalwebteam.store_api.stores." + "snapstore.SnapPublisher.get_publisher_metrics" + ) + def test_get_active_devices_weekly_installed_by_version( + self, + mock_get_publisher_metrics, + mock_get_item_details, + mock_is_authenticated, + ): + + mock_is_authenticated.return_value = True countries = [ {"values": [2], "name": "FR"}, {"values": [3], "name": "GB"}, ] - payload = { + mock_get_item_details.return_value = {"snap-id": "id"} + mock_get_publisher_metrics.return_value = { "metrics": [ { - "status": "OK", - "series": [ - {"values": random_values, "name": "ubuntu/0.1"} - ], - "buckets": dates, - "metric_name": "weekly_installed_base_by_operating_system", - }, - { - "status": "OK", - "series": countries, - "buckets": ["2018-03-18"], + "buckets": ["2024-09-17"], "metric_name": "weekly_installed_base_by_country", - }, + "series": countries, + "snap_id": "id", + "status": "OK", + } ] } - responses.add(responses.POST, self.api_url, json=payload, status=200) - - response = self.client.get( - self.endpoint_url + "?period=3m&active-devices=os" - ) - - self.assertEqual(3, len(responses.calls)) - called = responses.calls[0] - self.assertEqual(self.info_url, called.request.url) - self.assertEqual( - self.authorization, called.request.headers.get("Authorization") - ) - called = responses.calls[1] - self.assertEqual(self.api_url, called.request.url) - self.assertEqual( - self.authorization, called.request.headers.get("Authorization") - ) + response = self.client.get(self.endpoint_url) self.assertEqual(response.status_code, 200) - self.assert_template_used("publisher/metrics.html") - self.assert_context("snap_name", self.snap_name) - self.assert_context("snap_title", "Test Snap") - self.assert_context("metric_period", "3m") - self.assert_context("active_device_metric", "os") - self.assert_context("nodata", False) + response_json = response.json + active_devices = response_json["active_devices"] + + for info in active_devices: + country_info = active_devices[info] + if country_info["code"] == "FR": + self.assertEqual(country_info["number_of_users"], 2) + self.assertEqual(country_info["color_rgb"], [66, 146, 198]) + elif country_info["code"] == "GB": + self.assertEqual(country_info["number_of_users"], 3) + self.assertEqual(country_info["color_rgb"], [8, 48, 107]) + else: + self.assertEqual(country_info["number_of_users"], 0) + self.assertEqual(country_info["color_rgb"], [218, 218, 218]) diff --git a/webapp/metrics/helper.py b/webapp/metrics/helper.py index 8be82cde16..4700ea980b 100644 --- a/webapp/metrics/helper.py +++ b/webapp/metrics/helper.py @@ -22,7 +22,7 @@ def get_last_metrics_processed_date(): return last_metrics_processed.date() - one_day -def build_metrics_json( +def build_metric_query_installed_base( snap_id, installed_base, metric_period=30, metric_bucket="d" ): """Build the json that will be requested to the API @@ -32,8 +32,7 @@ def build_metrics_json( :param metric_period The metric period requested, by default 30 :param metric_bucket The metric bucket, by default 'd' - :returns A dictionary with the filters for the metrics API, by default - returns also the 'weekly_installed_base_by_country'. + :returns A dictionary with the filters for the metrics API. """ end = get_last_metrics_processed_date() @@ -55,6 +54,25 @@ def build_metrics_json( start=start, end=end, ), + ] + } + + +def build_metric_query_country(snap_id): + """Build the json that will be requested to the API + + :param snap_id The snap id + :param installed_base_metric The base metric requested + :param metric_period The metric period requested, by default 30 + :param metric_bucket The metric bucket, by default 'd' + + :returns A dictionary with the filters for the metrics API, by default + returns also the 'weekly_installed_base_by_country'. + """ + end = get_last_metrics_processed_date() + + return { + "filters": [ get_filter( metric_name="weekly_installed_base_by_country", snap_id=snap_id, diff --git a/webapp/publisher/snaps/metrics_views.py b/webapp/publisher/snaps/metrics_views.py index 77eb56ca74..29ec00021b 100644 --- a/webapp/publisher/snaps/metrics_views.py +++ b/webapp/publisher/snaps/metrics_views.py @@ -5,7 +5,10 @@ import flask import webapp.metrics.helper as metrics_helper import webapp.metrics.metrics as metrics -from canonicalwebteam.store_api.stores.snapstore import SnapPublisher +from canonicalwebteam.store_api.stores.snapstore import ( + SnapPublisher, + SnapStore, +) # Local from webapp.helpers import api_publisher_session @@ -13,6 +16,7 @@ from webapp.publisher.snaps import logic publisher_api = SnapPublisher(api_publisher_session) +store_api = SnapStore(api_publisher_session) @login_required @@ -51,14 +55,23 @@ def get_measure_snap(snap_name): def publisher_snap_metrics(snap_name): """ A view to display the snap metrics page for specific snaps. - - This queries the snapcraft API (api.snapcraft.io) and passes - some of the data through to the publisher/metrics.html template, - with appropriate sanitation. """ - details = publisher_api.get_snap_info(snap_name, flask.session) - default_track = details.get("default_track", "latest") + context = { + # Data direct from details API + "snap_name": snap_name, + # pass snap id from here? + "is_linux": "Linux" in flask.request.headers["User-Agent"], + } + + return flask.render_template("publisher/metrics.html", **context) + +@login_required +def get_active_devices(snap_name): + snap_details = store_api.get_item_details( + snap_name, api_version=2, fields=["snap-id"] + ) + snap_id = snap_details["snap-id"] metric_requested = logic.extract_metrics_period( flask.request.args.get("period", default="30d", type=str) ) @@ -68,8 +81,8 @@ def publisher_snap_metrics(snap_name): ) installed_base = logic.get_installed_based_metric(installed_base_metric) - metrics_query_json = metrics_helper.build_metrics_json( - snap_id=details["snap_id"], + metrics_query_json = metrics_helper.build_metric_query_installed_base( + snap_id=snap_id, installed_base=installed_base, metric_period=metric_requested["int"], metric_bucket=metric_requested["bucket"], @@ -79,24 +92,11 @@ def publisher_snap_metrics(snap_name): flask.session, json=metrics_query_json ) - latest_day_period = logic.extract_metrics_period("1d") - latest_installed_base = logic.get_installed_based_metric("version") - latest_day_query_json = metrics_helper.build_metrics_json( - snap_id=details["snap_id"], - installed_base=latest_installed_base, - metric_period=latest_day_period["int"], - metric_bucket=latest_day_period["bucket"], - ) - latest_day_response = publisher_api.get_publisher_metrics( - flask.session, json=latest_day_query_json - ) - active_metrics = metrics_helper.find_metric( metrics_response["metrics"], installed_base ) series = active_metrics["series"] - if active_metrics["metric_name"] == "weekly_installed_base_by_channel": for s in series: if "/" not in s["name"]: @@ -115,8 +115,19 @@ def publisher_snap_metrics(snap_name): status=active_metrics["status"], ) + # get latest active devices + latest_day_period = logic.extract_metrics_period("1d") + latest_installed_base = logic.get_installed_based_metric("version") + latest_day_query_json = metrics_helper.build_metric_query_installed_base( + snap_id=snap_id, + installed_base=latest_installed_base, + metric_period=latest_day_period["int"], + metric_bucket=latest_day_period["bucket"], + ) + latest_day_response = publisher_api.get_publisher_metrics( + flask.session, json=latest_day_query_json + ) latest_active = 0 - if active_devices: latest_active = active_devices.get_number_latest_active_devices() @@ -135,23 +146,17 @@ def publisher_snap_metrics(snap_name): latest_active_devices.get_number_latest_active_devices() ) - country_metric = metrics_helper.find_metric( - metrics_response["metrics"], "weekly_installed_base_by_country" - ) - country_devices = metrics.CountryDevices( - name=country_metric["metric_name"], - series=country_metric["series"], - buckets=country_metric["buckets"], - status=country_metric["status"], - private=True, + return flask.jsonify( + { + "active_devices": dict(active_devices), + "latest_active_devices": latest_active, + } ) - territories_total = 0 - if country_devices: - territories_total = country_devices.get_number_territories() - - nodata = not any([country_devices, active_devices]) +@login_required +def get_metric_annotaion(snap_name): + details = publisher_api.get_snap_info(snap_name, flask.session) annotations = {"name": "annotations", "series": [], "buckets": []} for category in details["categories"]["items"]: @@ -178,25 +183,41 @@ def publisher_snap_metrics(snap_name): annotations["series"] = sorted( annotations["series"], key=lambda k: k["date"] ) + return flask.jsonify(annotations) - context = { - # Data direct from details API - "snap_name": snap_name, - "snap_title": details["title"], - "publisher_name": details["publisher"]["display-name"], - "metric_period": metric_requested["period"], - "active_device_metric": installed_base_metric, - "default_track": default_track, - "private": details["private"], - # Metrics data - "nodata": nodata, - "latest_active_devices": latest_active, - "active_devices": dict(active_devices), - "territories_total": territories_total, - "territories": country_devices.country_data, - "active_devices_annotations": annotations, - # Context info - "is_linux": "Linux" in flask.request.headers["User-Agent"], - } - return flask.render_template("publisher/metrics.html", **context) +@login_required +def get_country_metric(snap_name): + snap_details = store_api.get_item_details( + snap_name, api_version=2, fields=["snap-id"] + ) + snap_id = snap_details["snap-id"] + metrics_query_json = metrics_helper.build_metric_query_country( + snap_id=snap_id, + ) + + metrics_response = publisher_api.get_publisher_metrics( + flask.session, json=metrics_query_json + ) + + country_metric = metrics_helper.find_metric( + metrics_response["metrics"], "weekly_installed_base_by_country" + ) + country_devices = metrics.CountryDevices( + name=country_metric["metric_name"], + series=country_metric["series"], + buckets=country_metric["buckets"], + status=country_metric["status"], + private=True, + ) + + territories_total = 0 + if country_devices: + territories_total = country_devices.get_number_territories() + + return flask.jsonify( + { + "active_devices": country_devices.country_data, + "territories_total": territories_total, + } + ) diff --git a/webapp/publisher/snaps/views.py b/webapp/publisher/snaps/views.py index 07d57c1a0e..82b6bc1121 100644 --- a/webapp/publisher/snaps/views.py +++ b/webapp/publisher/snaps/views.py @@ -211,6 +211,21 @@ view_func=metrics_views.publisher_snap_metrics, ) +publisher_snaps.add_url_rule( + "//metrics/active-devices", + view_func=metrics_views.get_active_devices, +) + +publisher_snaps.add_url_rule( + "//metrics/active-device-annotation", + view_func=metrics_views.get_metric_annotaion, +) + +publisher_snaps.add_url_rule( + "//metrics/country-metric", + view_func=metrics_views.get_country_metric, +) + # Publice views publisher_snaps.add_url_rule( "//publicise", diff --git a/webpack.config.entry.js b/webpack.config.entry.js index 50179b267f..0bdcf04789 100644 --- a/webpack.config.entry.js +++ b/webpack.config.entry.js @@ -21,4 +21,5 @@ module.exports = { "about-listing": "./static/js/public/about/listing.ts", store: "./static/js/store/index.tsx", "publisher-pages": "./static/js/publisher-pages/index.tsx", + metrics: "./static/js/publisher-pages/index.tsx", }; From 511c8aedd94c2d0c74d5d3f34683643afde94ae5 Mon Sep 17 00:00:00 2001 From: ilayda-cp Date: Thu, 3 Oct 2024 13:30:39 +0300 Subject: [PATCH 4/5] Wd 15262 improve performance of metric page (#4863) * feat: added downsampling and pagination --- jest.config.js | 3 + .../__tests__/useActiveDeviceMetrics.test.ts | 17 -- .../__tests__/useActiveDeviceMetrics.test.tsx | 167 ++++++++++++++++++ .../hooks/useActiveDeviceMetrics.ts | 128 ++++++++++++-- .../hooks/useLatestActiveDevicesMetric.ts | 26 +++ .../hooks/useMetricsAnnotation.ts | 2 + .../pages/Metrics/ActiveDeviceMetrics.tsx | 18 +- .../__tests__/ActiveDeviceMetrics.test.tsx | 26 ++- webapp/metrics/helper.py | 141 +++++++++++++-- webapp/publisher/snaps/metrics_views.py | 117 +++++++++--- webapp/publisher/snaps/views.py | 5 + 11 files changed, 566 insertions(+), 84 deletions(-) delete mode 100644 static/js/publisher-pages/hooks/__tests__/useActiveDeviceMetrics.test.ts create mode 100644 static/js/publisher-pages/hooks/__tests__/useActiveDeviceMetrics.test.tsx create mode 100644 static/js/publisher-pages/hooks/useLatestActiveDevicesMetric.ts diff --git a/jest.config.js b/jest.config.js index 01a13a0121..bfd1c4eeef 100644 --- a/jest.config.js +++ b/jest.config.js @@ -12,4 +12,7 @@ module.exports = { moduleNameMapper: { "\\.(scss|sass|css)$": "identity-obj-proxy", }, + globals: { + fetch: global.fetch, + }, }; diff --git a/static/js/publisher-pages/hooks/__tests__/useActiveDeviceMetrics.test.ts b/static/js/publisher-pages/hooks/__tests__/useActiveDeviceMetrics.test.ts deleted file mode 100644 index 0f4a6587ae..0000000000 --- a/static/js/publisher-pages/hooks/__tests__/useActiveDeviceMetrics.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as ReactQuery from "react-query"; -import { renderHook } from "@testing-library/react"; -import useActiveDeviceMetrics from "../useActiveDeviceMetrics"; - -describe("useActiveDeviceMetrics", () => { - test("Calls useQuery", () => { - jest.spyOn(ReactQuery, "useQuery").mockImplementation(jest.fn()); - renderHook(() => - useActiveDeviceMetrics({ - period: "30d", - snapId: "test-id", - type: "version", - }) - ); - expect(ReactQuery.useQuery).toHaveBeenCalled(); - }); -}); diff --git a/static/js/publisher-pages/hooks/__tests__/useActiveDeviceMetrics.test.tsx b/static/js/publisher-pages/hooks/__tests__/useActiveDeviceMetrics.test.tsx new file mode 100644 index 0000000000..2285f4ecad --- /dev/null +++ b/static/js/publisher-pages/hooks/__tests__/useActiveDeviceMetrics.test.tsx @@ -0,0 +1,167 @@ +import * as ReactQuery from "react-query"; +import { QueryClient, QueryClientProvider } from "react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import useActiveDeviceMetrics from "../useActiveDeviceMetrics"; + +describe("useActiveDeviceMetrics", () => { + test("Calls useQuery", () => { + const spy = jest.spyOn(ReactQuery, "useQuery").mockReturnValue({ + data: [], + status: "success", + isFetcing: false, + } as any); + + renderHook(() => + useActiveDeviceMetrics({ + period: "30d", + snapId: "test-id", + type: "version", + }) + ); + expect(ReactQuery.useQuery).toHaveBeenCalled(); + spy.mockRestore(); + }); + + const createWrapper = () => { + const queryClient = new QueryClient(); + return ({ children }: any) => ( + {children} + ); + }; + + test("if the page size is set to less than 3 months, do not paginate ", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + active_devices: { + buckets: [ + "2024-09-26", + "2024-09-27", + "2024-09-28", + "2024-09-29", + "2024-09-30", + ], + name: "weekly_installed_base_by_version", + series: [ + { + name: "1.0", + values: [5, 5, 0, 4, 4], + }, + ], + }, + latest_active_devices: 4, + total_page_num: 1, + }), + ok: true, + }) + ) as jest.Mock; + + const { result } = renderHook( + () => + useActiveDeviceMetrics({ + period: "30d", + snapId: "test-id", + type: "version", + }), + { + wrapper: createWrapper(), + } + ); + + await waitFor(() => expect(result.current.status).toBe("success")); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(result.current.data).toMatchObject({ + activeDevices: { + buckets: [ + "2024-09-26", + "2024-09-27", + "2024-09-28", + "2024-09-29", + "2024-09-30", + ], + series: [{ name: "1.0", values: [5, 5, 0, 4, 4] }], + }, + }); + (global.fetch as jest.Mock).mockRestore(); + }); + + test("if the page size is greater than 3 months, request data over multiple requests", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + active_devices: { + buckets: [ + "2024-09-26", + "2024-09-27", + "2024-09-28", + "2024-09-29", + "2024-09-30", + ], + name: "weekly_installed_base_by_version", + series: [ + { + name: "1.0", + values: [5, 5, 0, 4, 4], + }, + ], + }, + latest_active_devices: 4, + total_page_num: 1, + }), + ok: true, + }) + ) as jest.Mock; + + const { result } = renderHook( + () => + useActiveDeviceMetrics({ + period: "2y", + snapId: "test-id", + type: "version", + }), + { + wrapper: createWrapper(), + } + ); + + await waitFor(() => expect(result.current.status).toBe("success")); + + expect(global.fetch).toHaveBeenCalledTimes(8); + (global.fetch as jest.Mock).mockRestore(); + }); + + test("if the request 404, empty data should be returned", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + json: () => Promise.resolve(undefined), + ok: false, + status: 404, + }) + ) as jest.Mock; + + const { result } = renderHook( + () => + useActiveDeviceMetrics({ + period: "30d", + snapId: "test-id", + type: "version", + }), + { + wrapper: createWrapper(), + } + ); + await waitFor(() => expect(result.current.status).toBe("success")); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(result.current.data).toMatchObject({ + activeDevices: { + buckets: [], + series: [], + }, + }); + (global.fetch as jest.Mock).mockRestore(); + }); +}); diff --git a/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts b/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts index 3bac68434e..eea8ceba41 100644 --- a/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts +++ b/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts @@ -9,32 +9,122 @@ function useActiveDeviceMetrics({ period: string; type: string; }) { - return useQuery({ + const { status, data, isFetching } = useQuery({ queryKey: ["activeDeviceMetrics", snapId, period, type], queryFn: async () => { - const response = await fetch( - `/${snapId}/metrics/active-devices?period=${period}&active-devices=${type}` + return await fetchData(); + }, + retry: 0, + refetchOnWindowFocus: false, + }); + + const parsePeriod = (period: string) => { + const [_, periodLength, periodTime] = period.trim().split(/(\d+)/); + return { periodLength: +periodLength, periodTime }; + }; + + const fetchData = async () => { + const { periodTime, periodLength } = parsePeriod(period); + const pagePeriodLengthInMonths = 3; + + let totalPage = 1; + if ( + periodTime === "d" || + (periodTime === "m" && periodLength <= pagePeriodLengthInMonths) + ) { + totalPage = 1; + } else { + totalPage = + periodTime === "y" + ? Math.floor((periodLength * 12) / pagePeriodLengthInMonths) + : Math.floor(periodLength / pagePeriodLengthInMonths); + } + + const responses = []; + for (let i = 1; i <= totalPage; i++) { + responses.push( + fetch( + `/${snapId}/metrics/active-devices?active-devices=${type}&period=${period}&page=${i}&page-length=${pagePeriodLengthInMonths}` + ) ); + } + + const results = await Promise.all(responses); + + return await formatData(results); + }; + + const handleResponse = async (response: Response) => { + if (!response.ok) { + if (response.status === 404) { + return { + active_devices: { + buckets: [], + series: [], + }, + }; + } else { + throw new Error("Unable to fetch latest active device information"); + } + } + + const data = await response.json(); + return data; + }; + + const formatData = async (results: Response[]) => { + const buckets = []; + const series = new Map(); - if (!response.ok) { - if (response.status === 404) { - return { - latest_active_devices: 0, - active_devices: { - series: [], - buckets: [], - }, - }; - } else { - throw new Error("Unable to fetch active device metrics"); + let seriesThatAreAddedBefore = 0; + + for (const result of results.reverse()) { + const data = await handleResponse(result); + + const activeDeviceBuckets = data.active_devices.buckets; + + buckets.push(...activeDeviceBuckets); + // fill the array with 0's if the batch doesnt have that previous series + for (const seriesKey of series.keys()) { + const seriesExistInBatch = data.active_devices.series.find( + (activeDeviceSeries: { name: string }) => + activeDeviceSeries.name === seriesKey + ); + if (!seriesExistInBatch) { + series.set(seriesKey, [ + ...series.get(seriesKey), + ...new Array(activeDeviceBuckets.length).fill(0), + ]); } } - return await response.json(); - }, - retry: 0, - refetchOnWindowFocus: false, - }); + // fill the array with 0's if new series introduced in the batch + for (const activeDeviceSeries of data.active_devices.series) { + const key = activeDeviceSeries.name; + const prevData = series.has(key) + ? series.get(key) + : new Array(seriesThatAreAddedBefore).fill(0); + + series.set(key, [...prevData, ...activeDeviceSeries.values]); + } + + seriesThatAreAddedBefore += activeDeviceBuckets.length; + } + + const resultArray = Array.from(series.entries()).map(([key, value]) => ({ + name: key, + values: value, + })); + + return { + activeDevices: { + buckets, + series: resultArray, + }, + }; + }; + + return { status, data, isFetching }; } export default useActiveDeviceMetrics; diff --git a/static/js/publisher-pages/hooks/useLatestActiveDevicesMetric.ts b/static/js/publisher-pages/hooks/useLatestActiveDevicesMetric.ts new file mode 100644 index 0000000000..d97d6dddd1 --- /dev/null +++ b/static/js/publisher-pages/hooks/useLatestActiveDevicesMetric.ts @@ -0,0 +1,26 @@ +import { useQuery } from "react-query"; + +function useLatestActiveDevicesMetric(snapId?: string) { + return useQuery({ + queryKey: ["latestActiveDevicesMetric", snapId], + queryFn: async () => { + const response = await fetch(`/${snapId}/metrics/active-latest-devices`); + + if (!response.ok) { + if (response.status === 404) { + return null; + } else { + throw new Error("Unable to fetch latest active device information"); + } + } + + const data = await response.json(); + const activeDevices = data.latest_active_devices; + return String(activeDevices).replace(/(.)(?=(\d{3})+$)/g, "$1,"); + }, + retry: 0, + refetchOnWindowFocus: false, + }); +} + +export default useLatestActiveDevicesMetric; diff --git a/static/js/publisher-pages/hooks/useMetricsAnnotation.ts b/static/js/publisher-pages/hooks/useMetricsAnnotation.ts index 3e82ff575d..d28850e836 100644 --- a/static/js/publisher-pages/hooks/useMetricsAnnotation.ts +++ b/static/js/publisher-pages/hooks/useMetricsAnnotation.ts @@ -16,6 +16,8 @@ function useMetricsAnnotation(snapId?: string) { return data; }, + retry: 0, + refetchOnWindowFocus: false, }); } diff --git a/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx b/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx index 9067f18c13..13a17d7cc0 100644 --- a/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx +++ b/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx @@ -7,6 +7,7 @@ import { select } from "d3-selection"; import ActiveDeviceAnnotation from "./ActiveDeviceAnnotation"; import { ActiveDeviceMetricFilter } from "./ActiveDeviceMetricFilter"; import useActiveDeviceMetrics from "../../hooks/useActiveDeviceMetrics"; +import useLatestActiveDevicesMetric from "../../hooks/useLatestActiveDevicesMetric"; function ActiveDeviceMetrics({ isEmpty, @@ -17,12 +18,11 @@ function ActiveDeviceMetrics({ }): JSX.Element { const { snapId } = useParams(); const [searchParams, setSearchParams] = useSearchParams(); - const [latestActiveDevices, setLatestActiveDevices] = useState( - null - ); + const { data: latestActiveDevices } = useLatestActiveDevicesMetric(snapId); const period = searchParams.get("period") ?? "30d"; const type = searchParams.get("active-devices") ?? "version"; + const selector = "#activeDevices"; const { status, data, isFetching } = useActiveDeviceMetrics({ @@ -33,19 +33,13 @@ function ActiveDeviceMetrics({ useEffect(() => { if (data) { - const activeDevices = data.latest_active_devices; - activeDevices && - setLatestActiveDevices( - String(activeDevices).replace(/(.)(?=(\d{3})+$)/g, "$1,") - ); - - data.active_devices && + data.activeDevices && renderActiveDevicesMetrics({ selector, - metrics: data.active_devices, + metrics: data.activeDevices, type, }); - onDataLoad(data.active_devices?.buckets?.length); + onDataLoad(data.activeDevices?.buckets?.length); } }, [data]); diff --git a/static/js/publisher-pages/pages/Metrics/__tests__/ActiveDeviceMetrics.test.tsx b/static/js/publisher-pages/pages/Metrics/__tests__/ActiveDeviceMetrics.test.tsx index c000b7984e..1543a8c562 100644 --- a/static/js/publisher-pages/pages/Metrics/__tests__/ActiveDeviceMetrics.test.tsx +++ b/static/js/publisher-pages/pages/Metrics/__tests__/ActiveDeviceMetrics.test.tsx @@ -95,14 +95,15 @@ describe("ActiveDeviceMetrics", () => { status: "success", data: mockActiveDeviceMetrics, }; + } else if (params.queryKey[0] === "latestActiveDevicesMetric") { + return { + status: "success", + data: 5, + }; } else { return { status: "success", - data: { - buckets: [], - name: "annotations", - series: [], - }, + data: undefined, }; } } @@ -131,6 +132,11 @@ describe("ActiveDeviceMetrics", () => { status: "error", data: undefined, }; + } else if (params.queryKey[0] === "latestActiveDevicesMetric") { + return { + status: "success", + data: 5, + }; } else { return { status: "success", @@ -166,6 +172,11 @@ describe("ActiveDeviceMetrics", () => { isFetching: true, data: undefined, }; + } else if (params.queryKey[0] === "latestActiveDevicesMetric") { + return { + status: "success", + data: 5, + }; } else { return { status: "success", @@ -199,6 +210,11 @@ describe("ActiveDeviceMetrics", () => { status: "success", data: undefined, }; + } else if (params.queryKey[0] === "latestActiveDevicesMetric") { + return { + status: "success", + data: 0, + }; } else { return { status: "success", diff --git a/webapp/metrics/helper.py b/webapp/metrics/helper.py index 4700ea980b..c60d60aa56 100644 --- a/webapp/metrics/helper.py +++ b/webapp/metrics/helper.py @@ -1,5 +1,6 @@ import datetime from dateutil import relativedelta +import math def get_filter(metric_name, snap_id, start, end): @@ -22,6 +23,21 @@ def get_last_metrics_processed_date(): return last_metrics_processed.date() - one_day +def get_dates_for_metric(metric_period=30, metric_bucket="d"): + end = get_last_metrics_processed_date() + + if metric_bucket == "d": + start = end + relativedelta.relativedelta(days=-metric_period) + elif metric_bucket == "m": + start = end + relativedelta.relativedelta(months=-metric_period) + elif metric_bucket == "y": + # Go back an extra day to ensure the granularity increases + start = end + relativedelta.relativedelta( + years=-metric_period, days=-1 + ) + return {"end": end, "start": start} + + def build_metric_query_installed_base( snap_id, installed_base, metric_period=30, metric_bucket="d" ): @@ -34,18 +50,21 @@ def build_metric_query_installed_base( :returns A dictionary with the filters for the metrics API. """ - end = get_last_metrics_processed_date() - if metric_bucket == "d": - start = end + relativedelta.relativedelta(days=-metric_period) - elif metric_bucket == "m": - start = end + relativedelta.relativedelta(months=-metric_period) - elif metric_bucket == "y": - # Go back an extra day to ensure the granularity increases - start = end + relativedelta.relativedelta( - years=-metric_period, days=-1 - ) + dates = get_dates_for_metric(metric_period, metric_bucket) + return { + "filters": [ + get_filter( + metric_name=installed_base, + snap_id=snap_id, + start=dates["start"], + end=dates["end"], + ), + ] + } + +def build_active_device_metric_query(snap_id, installed_base, end, start): return { "filters": [ get_filter( @@ -147,3 +166,105 @@ def transform_metrics(metrics, metrics_response, snaps): metrics["buckets"] = metric["buckets"] return metrics + + +def lttb_select_indices(values, target_size): + """ + Selects indices using the LTTB algorithm for downsampling, + treating None as 0. + """ + n = len(values) + if n <= target_size: + return list(range(n)) + + # Initialize bucket size + bucket_size = (n - 2) / (target_size - 2) + indices = [] + + current_bucket_start = 0 + for i in range(1, target_size - 1): + next_bucket_start = min(math.ceil((i + 1) * bucket_size), n - 1) + + max_area = 0 + max_area_idx = current_bucket_start + + point1 = ( + current_bucket_start, + ( + values[current_bucket_start] + if values[current_bucket_start] is not None + else 0 + ), + ) + point2 = ( + next_bucket_start, + ( + values[next_bucket_start] + if values[next_bucket_start] is not None + else 0 + ), + ) + + for j in range(current_bucket_start + 1, next_bucket_start): + val_j = values[j] if values[j] is not None else 0 + + # Area of triangle formed by point1, point2, and the current point + area = abs( + (point1[0] - point2[0]) * (val_j - point1[1]) + - (point1[0] - j) * (point2[1] - point1[1]) + ) + if area > max_area: + max_area = area + max_area_idx = j + + indices.append(max_area_idx) + current_bucket_start = next_bucket_start + + indices.append(n - 1) + return indices + + +def normalize_series(series, bucket_count): + """ + Ensure all value arrays in the series have the same size + by padding with 0s. + """ + for item in series: + values = item["values"] + # If the series has no values, fill it with 0s + if not values: + item["values"] = [0] * bucket_count + # Extend the values with 0 if they are shorter than the bucket count + elif len(values) < bucket_count: + item["values"].extend([0] * (bucket_count - len(values))) + + +def downsample_series(buckets, series, target_size): + """Downsample each series in the data, treating None as 0.""" + downsampled_buckets = [] + downsampled_series = [] + + # Handle case where series is empty + if not series: + return buckets[:target_size], [] + + bucket_count = len(buckets) + # Normalize series first to make sure all series have the same length + normalize_series(series, bucket_count) + + # Downsample each series independently + for item in series: + name = item["name"] + values = item["values"] + + selected_indices = lttb_select_indices(values, target_size) + + # Collect the buckets and values based on the selected indices + downsampled_buckets = [buckets[i] for i in selected_indices] + downsampled_values = [ + values[i] if values[i] is not None else 0 for i in selected_indices + ] + + downsampled_series.append({"name": name, "values": downsampled_values}) + + return downsampled_buckets, downsampled_series diff --git a/webapp/publisher/snaps/metrics_views.py b/webapp/publisher/snaps/metrics_views.py index 29ec00021b..0588846f6d 100644 --- a/webapp/publisher/snaps/metrics_views.py +++ b/webapp/publisher/snaps/metrics_views.py @@ -1,5 +1,7 @@ # Standard library from json import loads +from dateutil import relativedelta +import math # Packages import flask @@ -15,9 +17,13 @@ from webapp.decorators import login_required from webapp.publisher.snaps import logic + publisher_api = SnapPublisher(api_publisher_session) store_api = SnapStore(api_publisher_session) +downsample_data_limit = 500 +downsample_target_size = 10 + @login_required def get_account_snaps_metrics(): @@ -68,53 +74,124 @@ def publisher_snap_metrics(snap_name): @login_required def get_active_devices(snap_name): + snap_details = store_api.get_item_details( snap_name, api_version=2, fields=["snap-id"] ) + snap_id = snap_details["snap-id"] - metric_requested = logic.extract_metrics_period( - flask.request.args.get("period", default="30d", type=str) - ) installed_base_metric = logic.verify_base_metrics( flask.request.args.get("active-devices", default="version", type=str) ) + period = flask.request.args.get("period", default="30d", type=str) + active_device_period = logic.extract_metrics_period(period) + + page = flask.request.args.get("page", default=1, type=int) + + metric_requested_length = active_device_period["int"] + metric_requested_bucket = active_device_period["bucket"] + + page_time_length = flask.request.args.get( + "page-length", default=3, type=int + ) + total_page_num = 1 + if metric_requested_bucket == "d" or ( + metric_requested_bucket == "m" + and page_time_length >= metric_requested_length + ): + dates = metrics_helper.get_dates_for_metric( + metric_requested_length, metric_requested_bucket + ) + start = dates["start"] + end = dates["end"] + else: + page_period_length = ( + (metric_requested_length * 12) + if metric_requested_bucket == "y" + else metric_requested_length + ) + total_page_num = math.floor(page_period_length / page_time_length) + + end = metrics_helper.get_last_metrics_processed_date() + ( + relativedelta.relativedelta( + months=-(page_time_length * (page - 1)) + ) + ) + start = end + (relativedelta.relativedelta(months=-(page_time_length))) + + # Decrease the date by a day to make sure + # there is no overlapping dates across the pages. + if page != 1: + end = end + relativedelta.relativedelta(days=-1) + installed_base = logic.get_installed_based_metric(installed_base_metric) - metrics_query_json = metrics_helper.build_metric_query_installed_base( - snap_id=snap_id, - installed_base=installed_base, - metric_period=metric_requested["int"], - metric_bucket=metric_requested["bucket"], + + new_metrics_query = metrics_helper.build_active_device_metric_query( + snap_id=snap_id, installed_base=installed_base, end=end, start=start ) metrics_response = publisher_api.get_publisher_metrics( - flask.session, json=metrics_query_json + flask.session, json=new_metrics_query ) active_metrics = metrics_helper.find_metric( metrics_response["metrics"], installed_base ) - series = active_metrics["series"] - if active_metrics["metric_name"] == "weekly_installed_base_by_channel": + metrics_data = active_metrics + buckets = metrics_data["buckets"] + series = metrics_data["series"] + metric_name = metrics_data["metric_name"] + # Add constants to a variable + if len(series) > downsample_data_limit: + downsampled_buckets, downsampled_series = ( + metrics_helper.downsample_series( + buckets, series, downsample_target_size + ) + ) + else: + downsampled_buckets = buckets + downsampled_series = series + + series = downsampled_series + if metric_name == "weekly_installed_base_by_channel": for s in series: if "/" not in s["name"]: s["name"] = f"latest/{s['name']}" if installed_base_metric == "os": - capitalized_series = active_metrics["series"] - for item in capitalized_series: + for item in series: item["name"] = metrics._capitalize_os_name(item["name"]) - series = capitalized_series active_devices = metrics.ActiveDevices( - name=active_metrics["metric_name"], + name=metric_name, series=series, - buckets=active_metrics["buckets"], - status=active_metrics["status"], + buckets=downsampled_buckets, + status=metrics_data["status"], + ) + + latest_active = 0 + if active_devices: + latest_active = active_devices.get_number_latest_active_devices() + + return flask.jsonify( + { + "active_devices": dict(active_devices), + "latest_active_devices": latest_active, + "total_page_num": total_page_num, + } ) + +@login_required +def get_latest_active_devices(snap_name): + snap_details = store_api.get_item_details( + snap_name, api_version=2, fields=["snap-id"] + ) + + snap_id = snap_details["snap-id"] # get latest active devices latest_day_period = logic.extract_metrics_period("1d") latest_installed_base = logic.get_installed_based_metric("version") @@ -124,12 +201,12 @@ def get_active_devices(snap_name): metric_period=latest_day_period["int"], metric_bucket=latest_day_period["bucket"], ) + latest_day_response = publisher_api.get_publisher_metrics( flask.session, json=latest_day_query_json ) + latest_active = 0 - if active_devices: - latest_active = active_devices.get_number_latest_active_devices() if latest_day_response: latest_active_metrics = metrics_helper.find_metric( @@ -145,10 +222,8 @@ def get_active_devices(snap_name): latest_active = ( latest_active_devices.get_number_latest_active_devices() ) - return flask.jsonify( { - "active_devices": dict(active_devices), "latest_active_devices": latest_active, } ) diff --git a/webapp/publisher/snaps/views.py b/webapp/publisher/snaps/views.py index 82b6bc1121..40b978a9ac 100644 --- a/webapp/publisher/snaps/views.py +++ b/webapp/publisher/snaps/views.py @@ -216,6 +216,11 @@ view_func=metrics_views.get_active_devices, ) +publisher_snaps.add_url_rule( + "//metrics/active-latest-devices", + view_func=metrics_views.get_latest_active_devices, +) + publisher_snaps.add_url_rule( "//metrics/active-device-annotation", view_func=metrics_views.get_metric_annotaion, From 4391accb0bdee7038cb4cf8b07b59c0fa8b1023b Mon Sep 17 00:00:00 2001 From: Steve Rydz Date: Thu, 3 Oct 2024 16:17:20 +0100 Subject: [PATCH 5/5] chore: Move listing page into publisher app (#4865) --- static/js/global.d.ts | 3 + .../SaveAndPreview/SaveAndPreview.tsx | 16 +- .../components/SectionNav/SectionNav.tsx | 7 +- .../__tests__/useMutateListingData.test.ts | 4 +- .../hooks/__tests__/useVerified.test.ts | 0 static/js/publisher-pages/hooks/index.ts | 9 +- .../hooks/useMutateListingData.ts | 13 +- .../hooks/useVerified.ts | 0 static/js/publisher-pages/index.tsx | 5 + .../AdditionalInformation.tsx | 4 +- .../AdditionalInformation/LicenseInputs.tsx | 0 .../AdditionalInformation/LicenseSearch.tsx | 0 .../Listing}/AdditionalInformation/index.ts | 0 .../ContactInformation/ContactFields.tsx | 0 .../ContactInformation/ContactInformation.tsx | 4 +- .../ContactInformation/PrimaryDomainInput.tsx | 12 +- .../__tests__/PrimaryDomainInput.test.tsx | 43 +++-- .../Listing}/ContactInformation/index.ts | 0 .../pages/Listing/Listing.tsx} | 25 +-- .../Listing}/ListingDetails/ImageUpload.tsx | 10 +- .../ListingDetails/ListingDetails.tsx | 4 +- .../Listing}/ListingDetails/Screenshot.tsx | 0 .../ListingDetails/ScreenshotList.tsx | 0 .../Listing}/ListingDetails/Screenshots.tsx | 2 +- .../pages/Listing}/ListingDetails/index.ts | 0 .../Listing}/ListingForm/ListingForm.tsx | 35 ++-- .../pages/Listing}/ListingForm/index.ts | 0 .../Listing}/PreviewForm/PreviewForm.tsx | 0 .../pages/Listing}/PreviewForm/index.ts | 0 .../publisher-pages/pages/Listing/index.tsx | 20 +++ .../pages/Publicise/Publicise.tsx | 90 ++++++----- .../pages/Settings/Settings.tsx | 4 +- static/js/publisher-pages/routes/root.tsx | 2 +- static/js/publisher-pages/test-utils/index.ts | 3 + .../test-utils/mockListingData.ts} | 2 +- static/js/publisher-pages/types/index.d.ts | 37 +++++ .../utils/__tests__/addDateToFilename.test.ts | 0 .../__tests__/formatImageChanges.test.ts | 0 .../__tests__/getDefaultListingData.test.ts} | 6 +- .../shouldShowUpdateMetadataWarning.test.ts | 0 .../__tests__/validateImageDimensions.test.ts | 0 .../utils/addDateToFilename.ts | 0 .../utils/formatImageChanges.ts | 0 .../utils/getDefaultListingData.ts} | 6 +- .../utils/getListingChanges.ts} | 6 +- .../{getChanges.ts => getSettingsChanges.ts} | 4 +- ...{getFormData.ts => getSettingsFormData.ts} | 8 +- static/js/publisher-pages/utils/index.ts | 22 ++- .../utils/shouldShowUpdateMetadataWarning.ts | 0 .../utils/validateImageDimensions.ts | 0 .../components/App/__tests__/App.test.tsx | 150 ------------------ .../publisher/listing/components/App/index.ts | 1 - static/js/publisher/listing/hooks/index.ts | 4 - static/js/publisher/listing/index.tsx | 18 --- .../js/publisher/listing/test-utils/index.ts | 3 - static/js/publisher/listing/types/index.d.ts | 45 ------ static/js/publisher/listing/utils/index.ts | 15 -- static/js/publisher/tour.tsx | 6 +- static/js/publisher/tour/helpers.ts | 4 +- static/js/publisher/tour/tour.tsx | 4 +- static/js/publisher/tour/tourBar.tsx | 16 +- static/js/publisher/tour/tourOverlay.tsx | 4 +- static/js/publisher/tour/tourStepCard.tsx | 4 +- static/sass/_snapcraft_tour.scss | 2 +- static/sass/styles.scss | 4 + templates/publisher/listing.html | 14 -- templates/store/publisher.html | 4 + webapp/publisher/snaps/listing_views.py | 2 +- webpack.config.entry.js | 1 - 69 files changed, 294 insertions(+), 413 deletions(-) rename static/js/{publisher/listing => publisher-pages}/hooks/__tests__/useMutateListingData.test.ts (89%) rename static/js/{publisher/listing => publisher-pages}/hooks/__tests__/useVerified.test.ts (100%) rename static/js/{publisher/listing => publisher-pages}/hooks/useMutateListingData.ts (86%) rename static/js/{publisher/listing => publisher-pages}/hooks/useVerified.ts (100%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/AdditionalInformation/AdditionalInformation.tsx (97%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/AdditionalInformation/LicenseInputs.tsx (100%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/AdditionalInformation/LicenseSearch.tsx (100%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/AdditionalInformation/index.ts (100%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ContactInformation/ContactFields.tsx (100%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ContactInformation/ContactInformation.tsx (95%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ContactInformation/PrimaryDomainInput.tsx (97%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ContactInformation/__tests__/PrimaryDomainInput.test.tsx (87%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ContactInformation/index.ts (100%) rename static/js/{publisher/listing/components/App/App.tsx => publisher-pages/pages/Listing/Listing.tsx} (61%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ListingDetails/ImageUpload.tsx (96%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ListingDetails/ListingDetails.tsx (99%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ListingDetails/Screenshot.tsx (100%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ListingDetails/ScreenshotList.tsx (100%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ListingDetails/Screenshots.tsx (98%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ListingDetails/index.ts (100%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ListingForm/ListingForm.tsx (86%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ListingForm/index.ts (100%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/PreviewForm/PreviewForm.tsx (100%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/PreviewForm/index.ts (100%) create mode 100644 static/js/publisher-pages/pages/Listing/index.tsx create mode 100644 static/js/publisher-pages/test-utils/index.ts rename static/js/{publisher/listing/test-utils/mockData.ts => publisher-pages/test-utils/mockListingData.ts} (97%) rename static/js/{publisher/listing => publisher-pages}/utils/__tests__/addDateToFilename.test.ts (100%) rename static/js/{publisher/listing => publisher-pages}/utils/__tests__/formatImageChanges.test.ts (100%) rename static/js/{publisher/listing/utils/__tests__/getDefaultData.test.ts => publisher-pages/utils/__tests__/getDefaultListingData.test.ts} (89%) rename static/js/{publisher/listing => publisher-pages}/utils/__tests__/shouldShowUpdateMetadataWarning.test.ts (100%) rename static/js/{publisher/listing => publisher-pages}/utils/__tests__/validateImageDimensions.test.ts (100%) rename static/js/{publisher/listing => publisher-pages}/utils/addDateToFilename.ts (100%) rename static/js/{publisher/listing => publisher-pages}/utils/formatImageChanges.ts (100%) rename static/js/{publisher/listing/utils/getDefaultData.ts => publisher-pages/utils/getDefaultListingData.ts} (92%) rename static/js/{publisher/listing/utils/getChanges.ts => publisher-pages/utils/getListingChanges.ts} (96%) rename static/js/publisher-pages/utils/{getChanges.ts => getSettingsChanges.ts} (92%) rename static/js/publisher-pages/utils/{getFormData.ts => getSettingsFormData.ts} (90%) rename static/js/{publisher/listing => publisher-pages}/utils/shouldShowUpdateMetadataWarning.ts (100%) rename static/js/{publisher/listing => publisher-pages}/utils/validateImageDimensions.ts (100%) delete mode 100644 static/js/publisher/listing/components/App/__tests__/App.test.tsx delete mode 100644 static/js/publisher/listing/components/App/index.ts delete mode 100644 static/js/publisher/listing/hooks/index.ts delete mode 100644 static/js/publisher/listing/index.tsx delete mode 100644 static/js/publisher/listing/test-utils/index.ts delete mode 100644 static/js/publisher/listing/types/index.d.ts delete mode 100644 static/js/publisher/listing/utils/index.ts delete mode 100644 templates/publisher/listing.html diff --git a/static/js/global.d.ts b/static/js/global.d.ts index c7ca56fe19..200ee0127f 100644 --- a/static/js/global.d.ts +++ b/static/js/global.d.ts @@ -41,4 +41,7 @@ declare interface Window { whitelist_countries: string[]; whitelist_country_keys: string; }; + SNAP_LISTING_DATA: { + DNS_VERIFICATION_TOKEN: string; + }; } diff --git a/static/js/publisher-pages/components/SaveAndPreview/SaveAndPreview.tsx b/static/js/publisher-pages/components/SaveAndPreview/SaveAndPreview.tsx index a6b71a5685..eb2eb093ed 100644 --- a/static/js/publisher-pages/components/SaveAndPreview/SaveAndPreview.tsx +++ b/static/js/publisher-pages/components/SaveAndPreview/SaveAndPreview.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useRef } from "react"; import { Row, Col, Button } from "@canonical/react-components"; import debounce from "../../../libs/debounce"; @@ -20,6 +20,8 @@ function SaveAndPreview({ showPreview, }: Props) { const stickyBar = useRef(null); + const mainPanel = document.querySelector(".l-main") as HTMLElement; + const handleScroll = () => { stickyBar?.current?.classList.toggle( "sticky-shadow", @@ -27,13 +29,17 @@ function SaveAndPreview({ ); }; - useEffect(() => { - document.addEventListener("scroll", debounce(handleScroll, 10, false)); - }, []); + if (mainPanel) { + mainPanel.addEventListener("scroll", debounce(handleScroll, 10, false)); + } return ( <> -
+

diff --git a/static/js/publisher-pages/components/SectionNav/SectionNav.tsx b/static/js/publisher-pages/components/SectionNav/SectionNav.tsx index 9100043a12..7fd2bacaa4 100644 --- a/static/js/publisher-pages/components/SectionNav/SectionNav.tsx +++ b/static/js/publisher-pages/components/SectionNav/SectionNav.tsx @@ -9,11 +9,13 @@ type Props = { function SectionNav({ activeTab, snapName }: Props) { return ( { test("Calls useMutatation", () => { jest.spyOn(ReactQuery, "useMutation").mockImplementation(jest.fn()); renderHook(() => useMutateListingData({ - data: mockData, + data: mockListingData, dirtyFields: {}, getDefaultData: jest.fn(), refetch: jest.fn(), diff --git a/static/js/publisher/listing/hooks/__tests__/useVerified.test.ts b/static/js/publisher-pages/hooks/__tests__/useVerified.test.ts similarity index 100% rename from static/js/publisher/listing/hooks/__tests__/useVerified.test.ts rename to static/js/publisher-pages/hooks/__tests__/useVerified.test.ts diff --git a/static/js/publisher-pages/hooks/index.ts b/static/js/publisher-pages/hooks/index.ts index 2867be8665..6bac724388 100644 --- a/static/js/publisher-pages/hooks/index.ts +++ b/static/js/publisher-pages/hooks/index.ts @@ -1,4 +1,11 @@ import useValidationSets from "./useValidationSets"; import useValidationSet from "./useValidationSet"; +import useMutateListingData from "./useMutateListingData"; +import useVerified from "./useVerified"; -export { useValidationSets, useValidationSet }; +export { + useValidationSets, + useValidationSet, + useMutateListingData, + useVerified, +}; diff --git a/static/js/publisher/listing/hooks/useMutateListingData.ts b/static/js/publisher-pages/hooks/useMutateListingData.ts similarity index 86% rename from static/js/publisher/listing/hooks/useMutateListingData.ts rename to static/js/publisher-pages/hooks/useMutateListingData.ts index 441940653c..0a48c56026 100644 --- a/static/js/publisher/listing/hooks/useMutateListingData.ts +++ b/static/js/publisher-pages/hooks/useMutateListingData.ts @@ -1,11 +1,11 @@ import { useMutation } from "react-query"; -import { addDateToFilename, getChanges } from "../utils"; +import { addDateToFilename, getListingChanges } from "../utils"; -import type { Data } from "../types"; +import type { ListingData } from "../types"; type Options = { - data: Data; + data: ListingData; dirtyFields: any; getDefaultData: Function; refetch: Function; @@ -31,7 +31,7 @@ function useMutateListingData({ mutationFn: async (values: any) => { const formData = new FormData(); - const changes = getChanges(dirtyFields, values, data); + const changes = getListingChanges(dirtyFields, values, data); formData.set("csrf_token", window.CSRF_TOKEN); formData.set("snap_id", data.snap_id); @@ -77,9 +77,12 @@ function useMutateListingData({ } }, onSuccess: async () => { - setShowSuccessNotification(true); const response = await refetch(); + setShowSuccessNotification(true); reset(getDefaultData(response.data)); + + const mainPanel = document.querySelector(".l-main") as HTMLElement; + mainPanel.scrollTo({ top: 0, left: 0, behavior: "smooth" }); }, }); } diff --git a/static/js/publisher/listing/hooks/useVerified.ts b/static/js/publisher-pages/hooks/useVerified.ts similarity index 100% rename from static/js/publisher/listing/hooks/useVerified.ts rename to static/js/publisher-pages/hooks/useVerified.ts diff --git a/static/js/publisher-pages/index.tsx b/static/js/publisher-pages/index.tsx index fb21298089..0f58aad902 100644 --- a/static/js/publisher-pages/index.tsx +++ b/static/js/publisher-pages/index.tsx @@ -8,6 +8,7 @@ import Settings from "./pages/Settings"; import ValidationSets from "./pages/ValidationSets"; import ValidationSet from "./pages/ValidationSet"; import Metrics from "./pages/Metrics"; +import Listing from "./pages/Listing"; const router = createBrowserRouter([ { @@ -42,6 +43,10 @@ const router = createBrowserRouter([ path: "/:snapId/metrics", element: , }, + { + path: "/:snapId/listing", + element:

, + }, ], }, ]); diff --git a/static/js/publisher/listing/components/AdditionalInformation/AdditionalInformation.tsx b/static/js/publisher-pages/pages/Listing/AdditionalInformation/AdditionalInformation.tsx similarity index 97% rename from static/js/publisher/listing/components/AdditionalInformation/AdditionalInformation.tsx rename to static/js/publisher-pages/pages/Listing/AdditionalInformation/AdditionalInformation.tsx index 40a3ae3f01..a8a708a276 100644 --- a/static/js/publisher/listing/components/AdditionalInformation/AdditionalInformation.tsx +++ b/static/js/publisher-pages/pages/Listing/AdditionalInformation/AdditionalInformation.tsx @@ -9,10 +9,10 @@ import { Row, Col } from "@canonical/react-components"; import LicenseInputs from "./LicenseInputs"; -import type { Data } from "../../types"; +import type { ListingData } from "../../../types"; type Props = { - data: Data; + data: ListingData; register: UseFormRegister; getValues: UseFormGetValues; setValue: UseFormSetValue; diff --git a/static/js/publisher/listing/components/AdditionalInformation/LicenseInputs.tsx b/static/js/publisher-pages/pages/Listing/AdditionalInformation/LicenseInputs.tsx similarity index 100% rename from static/js/publisher/listing/components/AdditionalInformation/LicenseInputs.tsx rename to static/js/publisher-pages/pages/Listing/AdditionalInformation/LicenseInputs.tsx diff --git a/static/js/publisher/listing/components/AdditionalInformation/LicenseSearch.tsx b/static/js/publisher-pages/pages/Listing/AdditionalInformation/LicenseSearch.tsx similarity index 100% rename from static/js/publisher/listing/components/AdditionalInformation/LicenseSearch.tsx rename to static/js/publisher-pages/pages/Listing/AdditionalInformation/LicenseSearch.tsx diff --git a/static/js/publisher/listing/components/AdditionalInformation/index.ts b/static/js/publisher-pages/pages/Listing/AdditionalInformation/index.ts similarity index 100% rename from static/js/publisher/listing/components/AdditionalInformation/index.ts rename to static/js/publisher-pages/pages/Listing/AdditionalInformation/index.ts diff --git a/static/js/publisher/listing/components/ContactInformation/ContactFields.tsx b/static/js/publisher-pages/pages/Listing/ContactInformation/ContactFields.tsx similarity index 100% rename from static/js/publisher/listing/components/ContactInformation/ContactFields.tsx rename to static/js/publisher-pages/pages/Listing/ContactInformation/ContactFields.tsx diff --git a/static/js/publisher/listing/components/ContactInformation/ContactInformation.tsx b/static/js/publisher-pages/pages/Listing/ContactInformation/ContactInformation.tsx similarity index 95% rename from static/js/publisher/listing/components/ContactInformation/ContactInformation.tsx rename to static/js/publisher-pages/pages/Listing/ContactInformation/ContactInformation.tsx index 99e040663d..0da4c0bc5f 100644 --- a/static/js/publisher/listing/components/ContactInformation/ContactInformation.tsx +++ b/static/js/publisher-pages/pages/Listing/ContactInformation/ContactInformation.tsx @@ -9,10 +9,10 @@ import { import PrimaryDomainInput from "./PrimaryDomainInput"; import ContactFields from "./ContactFields"; -import type { Data } from "../../types"; +import type { ListingData } from "../../../types"; type Props = { - data: Data; + data: ListingData; register: UseFormRegister; control: Control; getFieldState: UseFormGetFieldState; diff --git a/static/js/publisher/listing/components/ContactInformation/PrimaryDomainInput.tsx b/static/js/publisher-pages/pages/Listing/ContactInformation/PrimaryDomainInput.tsx similarity index 97% rename from static/js/publisher/listing/components/ContactInformation/PrimaryDomainInput.tsx rename to static/js/publisher-pages/pages/Listing/ContactInformation/PrimaryDomainInput.tsx index 025c67d9c1..69e36dc70a 100644 --- a/static/js/publisher/listing/components/ContactInformation/PrimaryDomainInput.tsx +++ b/static/js/publisher-pages/pages/Listing/ContactInformation/PrimaryDomainInput.tsx @@ -9,12 +9,12 @@ import { import { nanoid } from "nanoid"; import { Row, Col, Modal } from "@canonical/react-components"; -import { useVerified } from "../../hooks"; +import { useVerified } from "../../../hooks"; -import type { Data } from "../../types"; +import type { ListingData } from "../../../types"; type Props = { - data: Data; + data: ListingData; register: UseFormRegister; getFieldState: UseFormGetFieldState; getValues: UseFormGetValues; @@ -26,14 +26,14 @@ function PrimaryDomainInput({ getFieldState, getValues, }: Props) { - const { snapName } = useParams(); + const { snapId } = useParams(); const id = nanoid(); const fieldState = getFieldState("primary_website"); const [showVerifyModal, setShowVerifyModal] = useState(false); - const { isLoading, status, data: verifiedData } = useVerified(snapName); + const { isLoading, status, data: verifiedData } = useVerified(snapId); const domain = getValues("primary_website"); const defaultDomain = data.primary_website; - const verificationToken = `SNAPCRAFT_IO_VERIFICATION=${window.DNS_VERIFICATION_TOKEN}`; + const verificationToken = `SNAPCRAFT_IO_VERIFICATION=${window.SNAP_LISTING_DATA.DNS_VERIFICATION_TOKEN}`; const noPathDomains = [ "github.com", diff --git a/static/js/publisher/listing/components/ContactInformation/__tests__/PrimaryDomainInput.test.tsx b/static/js/publisher-pages/pages/Listing/ContactInformation/__tests__/PrimaryDomainInput.test.tsx similarity index 87% rename from static/js/publisher/listing/components/ContactInformation/__tests__/PrimaryDomainInput.test.tsx rename to static/js/publisher-pages/pages/Listing/ContactInformation/__tests__/PrimaryDomainInput.test.tsx index 0c350f8929..7118742a2f 100644 --- a/static/js/publisher/listing/components/ContactInformation/__tests__/PrimaryDomainInput.test.tsx +++ b/static/js/publisher-pages/pages/Listing/ContactInformation/__tests__/PrimaryDomainInput.test.tsx @@ -7,11 +7,13 @@ import "@testing-library/jest-dom"; import PrimaryDomainInput from "../PrimaryDomainInput"; -import { mockData } from "../../../test-utils"; +import { mockListingData } from "../../../../test-utils"; -import type { Data } from "../../../types"; +import type { ListingData } from "../../../../types"; -window.DNS_VERIFICATION_TOKEN = "abc123"; +window.SNAP_LISTING_DATA = { + DNS_VERIFICATION_TOKEN: "abc123", +}; jest.mock("react-query", () => ({ ...jest.requireActual("react-query"), @@ -38,7 +40,10 @@ const mockUseFormReturnValue = { getValues: jest.fn().mockReturnValue("https://example.com"), }; -const renderComponent = (data: Data, defaultValues: { [key: string]: any }) => { +const renderComponent = ( + data: ListingData, + defaultValues: { [key: string]: any } +) => { const Component = () => { const { register, getFieldState, getValues } = useForm({ defaultValues, @@ -71,7 +76,9 @@ describe("PrimaryDomainInput", () => { // @ts-ignore useForm.mockImplementation(() => mockUseFormReturnValue); - renderComponent(mockData, { primary_website: "https://example.com" }); + renderComponent(mockListingData, { + primary_website: "https://example.com", + }); expect(screen.getByText("Verified ownership")).toBeInTheDocument(); }); @@ -92,7 +99,9 @@ describe("PrimaryDomainInput", () => { useForm.mockImplementation(() => mockUseFormReturnValue); const user = userEvent.setup(); - renderComponent(mockData, { primary_website: "https://example.com" }); + renderComponent(mockListingData, { + primary_website: "https://example.com", + }); const input = screen.getByRole("textbox", { name: "Primary website:" }); await user.type(input, "https://example.comabc"); expect(input).toHaveValue("https://example.comabc"); @@ -120,7 +129,9 @@ describe("PrimaryDomainInput", () => { useForm.mockImplementation(() => mockUseFormReturnValue); const user = userEvent.setup(); - renderComponent(mockData, { primary_website: "https://example.com" }); + renderComponent(mockListingData, { + primary_website: "https://example.com", + }); const input = screen.getByRole("textbox", { name: "Primary website:" }); await user.clear(input); await user.type(input, "/path"); @@ -141,7 +152,7 @@ describe("PrimaryDomainInput", () => { useForm.mockImplementation(() => mockUseFormReturnValue); const user = userEvent.setup(); renderComponent( - { ...mockData, primary_website: "https://launchpad.net" }, + { ...mockListingData, primary_website: "https://launchpad.net" }, { primary_website: "https://launchpad.net" } ); await user.type( @@ -166,7 +177,9 @@ describe("PrimaryDomainInput", () => { useForm.mockImplementation(() => mockUseFormReturnValue); const user = userEvent.setup(); - renderComponent(mockData, { primary_website: "https://example.com" }); + renderComponent(mockListingData, { + primary_website: "https://example.com", + }); await user.click( screen.getByRole("button", { name: "Verified ownership" }) ); @@ -187,7 +200,9 @@ describe("PrimaryDomainInput", () => { // @ts-ignore useForm.mockImplementation(() => mockUseFormReturnValue); - renderComponent(mockData, { primary_website: "https://example.com" }); + renderComponent(mockListingData, { + primary_website: "https://example.com", + }); expect(screen.queryByText("Verified ownership")).not.toBeInTheDocument(); expect( screen.getByRole("button", { name: "Verify ownership" }) @@ -209,7 +224,9 @@ describe("PrimaryDomainInput", () => { useForm.mockImplementation(() => mockUseFormReturnValue); const user = userEvent.setup(); - renderComponent(mockData, { primary_website: "https://example.com" }); + renderComponent(mockListingData, { + primary_website: "https://example.com", + }); await user.click(screen.getByRole("button", { name: "Verify ownership" })); expect( screen.getByRole("heading", { level: 2, name: "Verify ownership" }) @@ -234,7 +251,9 @@ describe("PrimaryDomainInput", () => { useForm.mockImplementation(() => mockUseFormReturnValue); const user = userEvent.setup(); - renderComponent(mockData, { primary_website: "https://example.com" }); + renderComponent(mockListingData, { + primary_website: "https://example.com", + }); expect( screen.getByRole("button", { name: "Verify ownership" }) ).toBeDisabled(); diff --git a/static/js/publisher/listing/components/ContactInformation/index.ts b/static/js/publisher-pages/pages/Listing/ContactInformation/index.ts similarity index 100% rename from static/js/publisher/listing/components/ContactInformation/index.ts rename to static/js/publisher-pages/pages/Listing/ContactInformation/index.ts diff --git a/static/js/publisher/listing/components/App/App.tsx b/static/js/publisher-pages/pages/Listing/Listing.tsx similarity index 61% rename from static/js/publisher/listing/components/App/App.tsx rename to static/js/publisher-pages/pages/Listing/Listing.tsx index d41960378b..219d51b204 100644 --- a/static/js/publisher/listing/components/App/App.tsx +++ b/static/js/publisher-pages/pages/Listing/Listing.tsx @@ -2,15 +2,15 @@ import { useParams } from "react-router-dom"; import { useQuery } from "react-query"; import { Strip } from "@canonical/react-components"; -import PageHeader from "../../../shared/PageHeader"; -import ListingForm from "../ListingForm"; +import SectionNav from "../../components/SectionNav"; +import ListingForm from "./ListingForm"; -function App(): JSX.Element { - const { snapName } = useParams(); +function Listing(): JSX.Element { + const { snapId } = useParams(); const { data, isLoading, refetch } = useQuery({ queryKey: ["listing"], queryFn: async () => { - const response = await fetch(`/api/${snapName}/listing`); + const response = await fetch(`/api/${snapId}/listing`); if (!response.ok) { throw new Error("There was a problem fetching listing data"); @@ -28,17 +28,18 @@ function App(): JSX.Element { return ( <> - +

+ My snaps / {snapId} / + Listing +

+ + {isLoading && (

 Loading{" "} - {snapName} listing data + {snapId} listing data

)} @@ -48,4 +49,4 @@ function App(): JSX.Element { ); } -export default App; +export default Listing; diff --git a/static/js/publisher/listing/components/ListingDetails/ImageUpload.tsx b/static/js/publisher-pages/pages/Listing/ListingDetails/ImageUpload.tsx similarity index 96% rename from static/js/publisher/listing/components/ListingDetails/ImageUpload.tsx rename to static/js/publisher-pages/pages/Listing/ListingDetails/ImageUpload.tsx index b9b224cc1d..5671be9878 100644 --- a/static/js/publisher/listing/components/ListingDetails/ImageUpload.tsx +++ b/static/js/publisher-pages/pages/Listing/ListingDetails/ImageUpload.tsx @@ -9,7 +9,7 @@ import { Icon, } from "@canonical/react-components"; -import { validateImageDimensions } from "../../utils"; +import { validateImageDimensions } from "../../../utils"; type Props = { imageUrl: string | null; @@ -236,12 +236,8 @@ function ImageUpload({ className="p-button--base snap-remove-icon" onClick={() => { setImageIsValid(true); - setValue(imageUrlFieldKey, "", { - shouldDirty: window?.listingData?.banner_urls[0] !== null, - }); - setValue(imageFieldKey, new File([], ""), { - shouldDirty: window?.listingData?.banner_urls[0] !== null, - }); + setValue(imageUrlFieldKey, ""); + setValue(imageFieldKey, new File([], "")); setPreviewImageUrl(""); }} > diff --git a/static/js/publisher/listing/components/ListingDetails/ListingDetails.tsx b/static/js/publisher-pages/pages/Listing/ListingDetails/ListingDetails.tsx similarity index 99% rename from static/js/publisher/listing/components/ListingDetails/ListingDetails.tsx rename to static/js/publisher-pages/pages/Listing/ListingDetails/ListingDetails.tsx index 96b5f3c39a..22e33bdb38 100644 --- a/static/js/publisher/listing/components/ListingDetails/ListingDetails.tsx +++ b/static/js/publisher-pages/pages/Listing/ListingDetails/ListingDetails.tsx @@ -11,10 +11,10 @@ import { Row, Col, Button, Icon } from "@canonical/react-components"; import ImageUpload from "./ImageUpload"; import Screenshots from "./Screenshots"; -import type { Data } from "../../types"; +import type { ListingData } from "../../../types"; type Props = { - data: Data; + data: ListingData; register: UseFormRegister; getValues: UseFormGetValues; setValue: UseFormSetValue; diff --git a/static/js/publisher/listing/components/ListingDetails/Screenshot.tsx b/static/js/publisher-pages/pages/Listing/ListingDetails/Screenshot.tsx similarity index 100% rename from static/js/publisher/listing/components/ListingDetails/Screenshot.tsx rename to static/js/publisher-pages/pages/Listing/ListingDetails/Screenshot.tsx diff --git a/static/js/publisher/listing/components/ListingDetails/ScreenshotList.tsx b/static/js/publisher-pages/pages/Listing/ListingDetails/ScreenshotList.tsx similarity index 100% rename from static/js/publisher/listing/components/ListingDetails/ScreenshotList.tsx rename to static/js/publisher-pages/pages/Listing/ListingDetails/ScreenshotList.tsx diff --git a/static/js/publisher/listing/components/ListingDetails/Screenshots.tsx b/static/js/publisher-pages/pages/Listing/ListingDetails/Screenshots.tsx similarity index 98% rename from static/js/publisher/listing/components/ListingDetails/Screenshots.tsx rename to static/js/publisher-pages/pages/Listing/ListingDetails/Screenshots.tsx index 1f365ada76..afca1437ba 100644 --- a/static/js/publisher/listing/components/ListingDetails/Screenshots.tsx +++ b/static/js/publisher-pages/pages/Listing/ListingDetails/Screenshots.tsx @@ -3,7 +3,7 @@ import { useFieldArray } from "react-hook-form"; import { nanoid } from "nanoid"; import { Row, Col, Notification } from "@canonical/react-components"; -import { validateImageDimensions } from "../../utils"; +import { validateImageDimensions } from "../../../utils"; import ScreenshotList from "./ScreenshotList"; diff --git a/static/js/publisher/listing/components/ListingDetails/index.ts b/static/js/publisher-pages/pages/Listing/ListingDetails/index.ts similarity index 100% rename from static/js/publisher/listing/components/ListingDetails/index.ts rename to static/js/publisher-pages/pages/Listing/ListingDetails/index.ts diff --git a/static/js/publisher/listing/components/ListingForm/ListingForm.tsx b/static/js/publisher-pages/pages/Listing/ListingForm/ListingForm.tsx similarity index 86% rename from static/js/publisher/listing/components/ListingForm/ListingForm.tsx rename to static/js/publisher-pages/pages/Listing/ListingForm/ListingForm.tsx index 1f2dd2ba53..ba348c76bd 100644 --- a/static/js/publisher/listing/components/ListingForm/ListingForm.tsx +++ b/static/js/publisher-pages/pages/Listing/ListingForm/ListingForm.tsx @@ -3,27 +3,30 @@ import { useParams } from "react-router-dom"; import { useForm, useFormState, FieldValues } from "react-hook-form"; import { Strip, Notification } from "@canonical/react-components"; -import SaveAndPreview from "../../../shared/SaveAndPreview"; +import SaveAndPreview from "../../../components/SaveAndPreview"; import ListingDetails from "../ListingDetails"; import ContactInformation from "../ContactInformation"; import AdditionalInformation from "../AdditionalInformation"; import PreviewForm from "../PreviewForm"; -import UpdateMetadataModal from "../../../shared/UpdateMetadataModal"; +import UpdateMetadataModal from "../../../components/UpdateMetadataModal"; -import { shouldShowUpdateMetadataWarning, getDefaultData } from "../../utils"; -import { initListingTour } from "../../../tour"; +import { + shouldShowUpdateMetadataWarning, + getDefaultListingData, +} from "../../../utils"; +import { initListingTour } from "../../../../publisher/tour"; -import { useMutateListingData } from "../../hooks"; +import { useMutateListingData } from "../../../hooks"; -import type { Data } from "../../types"; +import type { ListingData } from "../../../types"; type Props = { - data: Data; + data: ListingData; refetch: Function; }; function ListingForm({ data, refetch }: Props): JSX.Element { - const { snapName } = useParams(); + const { snapId } = useParams(); const { register, @@ -36,7 +39,7 @@ function ListingForm({ data, refetch }: Props): JSX.Element { handleSubmit, watch, } = useForm({ - defaultValues: getDefaultData(data), + defaultValues: getDefaultListingData(data), }); const { dirtyFields } = useFormState({ control }); @@ -57,13 +60,13 @@ function ListingForm({ data, refetch }: Props): JSX.Element { const { mutate, isLoading } = useMutateListingData({ data, dirtyFields, - getDefaultData, + getDefaultData: getDefaultListingData, refetch, reset, setShowSuccessNotification, setUpdateMetadataOnRelease, shouldShowUpdateMetadataWarning, - snapName, + snapName: snapId, }); useEffect(() => { @@ -71,13 +74,13 @@ function ListingForm({ data, refetch }: Props): JSX.Element { "tour-container" ) as HTMLElement; - if (snapName) { + if (snapId) { initListingTour({ - snapName, + snapName: snapId, container: tourContainer, formFields: { title: data.title, - snap_name: snapName, + snap_name: snapId, categories: [], video_urls: [], images: [], @@ -107,7 +110,7 @@ function ListingForm({ data, refetch }: Props): JSX.Element { })} > - {snapName && } + {snapId && }
); diff --git a/static/js/publisher/listing/components/ListingForm/index.ts b/static/js/publisher-pages/pages/Listing/ListingForm/index.ts similarity index 100% rename from static/js/publisher/listing/components/ListingForm/index.ts rename to static/js/publisher-pages/pages/Listing/ListingForm/index.ts diff --git a/static/js/publisher/listing/components/PreviewForm/PreviewForm.tsx b/static/js/publisher-pages/pages/Listing/PreviewForm/PreviewForm.tsx similarity index 100% rename from static/js/publisher/listing/components/PreviewForm/PreviewForm.tsx rename to static/js/publisher-pages/pages/Listing/PreviewForm/PreviewForm.tsx diff --git a/static/js/publisher/listing/components/PreviewForm/index.ts b/static/js/publisher-pages/pages/Listing/PreviewForm/index.ts similarity index 100% rename from static/js/publisher/listing/components/PreviewForm/index.ts rename to static/js/publisher-pages/pages/Listing/PreviewForm/index.ts diff --git a/static/js/publisher-pages/pages/Listing/index.tsx b/static/js/publisher-pages/pages/Listing/index.tsx new file mode 100644 index 0000000000..f7078049ef --- /dev/null +++ b/static/js/publisher-pages/pages/Listing/index.tsx @@ -0,0 +1,20 @@ +// import { createRoot } from "react-dom/client"; +// import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; +// import { QueryClient, QueryClientProvider } from "react-query"; +// import App from "./components/App"; + +// const queryClient = new QueryClient(); + +// const container = document.getElementById("main-content"); +// const root = createRoot(container as HTMLElement); +// root.render( +// +// +// +// } /> +// +// +// +// ); + +export { default } from "./Listing"; diff --git a/static/js/publisher-pages/pages/Publicise/Publicise.tsx b/static/js/publisher-pages/pages/Publicise/Publicise.tsx index 770f4222c0..71c693dc6a 100644 --- a/static/js/publisher-pages/pages/Publicise/Publicise.tsx +++ b/static/js/publisher-pages/pages/Publicise/Publicise.tsx @@ -1,8 +1,9 @@ -import { useParams, NavLink, Link } from "react-router-dom"; +import { useParams, NavLink } from "react-router-dom"; import { Row, Col, SideNavigation, + Strip, Notification, } from "@canonical/react-components"; @@ -39,49 +40,50 @@ function Publicise({ view }: Props): JSX.Element { - {disableView() && ( - - When your snap is public and has a release, you'll be able to share it - using Store buttons, badges and embeddable cards. Make your snap - public in its settings page. - - )} - - - - - - - {!view && } - {view === "badges" && } - {view === "cards" && } - - + + {disableView() && ( + + When your snap is public and has a release, you'll be able to share + it using Store buttons, badges and embeddable cards. Make your snap + public in its settings page. + + )} + + + + + + {!view && } + {view === "badges" && } + {view === "cards" && } + + + ); } diff --git a/static/js/publisher-pages/pages/Settings/Settings.tsx b/static/js/publisher-pages/pages/Settings/Settings.tsx index fb75796822..07f2907545 100644 --- a/static/js/publisher-pages/pages/Settings/Settings.tsx +++ b/static/js/publisher-pages/pages/Settings/Settings.tsx @@ -18,7 +18,7 @@ import UpdateMetadataModal from "../../components/UpdateMetadataModal"; import SaveStateNotifications from "../../components/SaveStateNotifications"; import { UnregisterSnapModal } from "./UnregisterSnapModal"; -import { getSettingsData, getFormData } from "../../utils"; +import { getSettingsData, getSettingsFormData } from "../../utils"; function Settings() { const { snapId } = useParams(); @@ -92,7 +92,7 @@ function Settings() { const response = await fetch(`/${data.snap_name}/settings.json`, { method: "POST", - body: getFormData(settingsData, dirtyFields, data), + body: getSettingsFormData(settingsData, dirtyFields, data), }); if (response.status !== 200) { diff --git a/static/js/publisher-pages/routes/root.tsx b/static/js/publisher-pages/routes/root.tsx index 77e878463f..6fe58ce9b5 100644 --- a/static/js/publisher-pages/routes/root.tsx +++ b/static/js/publisher-pages/routes/root.tsx @@ -25,7 +25,7 @@ function Root(): JSX.Element { /> } > -
+
diff --git a/static/js/publisher-pages/test-utils/index.ts b/static/js/publisher-pages/test-utils/index.ts new file mode 100644 index 0000000000..dc097f6463 --- /dev/null +++ b/static/js/publisher-pages/test-utils/index.ts @@ -0,0 +1,3 @@ +import { mockListingData } from "./mockListingData"; + +export { mockListingData }; diff --git a/static/js/publisher/listing/test-utils/mockData.ts b/static/js/publisher-pages/test-utils/mockListingData.ts similarity index 97% rename from static/js/publisher/listing/test-utils/mockData.ts rename to static/js/publisher-pages/test-utils/mockListingData.ts index 75ad1d48b5..467bcd7ff9 100644 --- a/static/js/publisher/listing/test-utils/mockData.ts +++ b/static/js/publisher-pages/test-utils/mockListingData.ts @@ -1,4 +1,4 @@ -export const mockData = { +export const mockListingData = { banner_urls: ["https://example.com/screenshot"], categories: [ { name: "Test category 1", slug: "test-category-1" }, diff --git a/static/js/publisher-pages/types/index.d.ts b/static/js/publisher-pages/types/index.d.ts index 922a6c6927..aa2e270670 100644 --- a/static/js/publisher-pages/types/index.d.ts +++ b/static/js/publisher-pages/types/index.d.ts @@ -33,3 +33,40 @@ export type SettingsData = { whitelist_countries: string[]; whitelist_country_keys: string; }; + +export type TourStep = { + id: string; + position?: string; + elements?: HTMLElement[]; + title: string; + content: string; +}; + +export type ListingData = { + snap_id: string; + title: string; + video_urls: string; + summary: string; + description: string; + categories: { name: string; slug: string }[]; + primary_category: string; + secondary_category: string; + websites: { url: string }[]; + contacts: { url: string }[]; + donations: { url: string }[]; + source_code: { url: string }[]; + issues: { url: string }[]; + primary_website: string; + public_metrics_enabled: boolean; + public_metrics_blacklist: string[]; + public_metrics_territories: boolean; + public_metrics_distros: boolean; + license: string; + license_type: string; + licenses: { key: string; name: string }[]; + icon_url: string; + screenshot_urls: string[]; + banner_urls: string[]; + update_metadata_on_release: boolean; + tour_steps: Step[]; +}; diff --git a/static/js/publisher/listing/utils/__tests__/addDateToFilename.test.ts b/static/js/publisher-pages/utils/__tests__/addDateToFilename.test.ts similarity index 100% rename from static/js/publisher/listing/utils/__tests__/addDateToFilename.test.ts rename to static/js/publisher-pages/utils/__tests__/addDateToFilename.test.ts diff --git a/static/js/publisher/listing/utils/__tests__/formatImageChanges.test.ts b/static/js/publisher-pages/utils/__tests__/formatImageChanges.test.ts similarity index 100% rename from static/js/publisher/listing/utils/__tests__/formatImageChanges.test.ts rename to static/js/publisher-pages/utils/__tests__/formatImageChanges.test.ts diff --git a/static/js/publisher/listing/utils/__tests__/getDefaultData.test.ts b/static/js/publisher-pages/utils/__tests__/getDefaultListingData.test.ts similarity index 89% rename from static/js/publisher/listing/utils/__tests__/getDefaultData.test.ts rename to static/js/publisher-pages/utils/__tests__/getDefaultListingData.test.ts index 964cd7222e..058788c9ef 100644 --- a/static/js/publisher/listing/utils/__tests__/getDefaultData.test.ts +++ b/static/js/publisher-pages/utils/__tests__/getDefaultListingData.test.ts @@ -1,10 +1,10 @@ -import getDefaultData from "../getDefaultData"; +import getDefaultListingData from "../getDefaultListingData"; -import { mockData } from "../../test-utils"; +import { mockListingData } from "../../test-utils"; describe("getDefaultData", () => { test("returns default data", () => { - const defaultData = getDefaultData(mockData); + const defaultData = getDefaultListingData(mockListingData); expect(defaultData.contacts).toBeDefined(); expect(defaultData.description).toBeDefined(); diff --git a/static/js/publisher/listing/utils/__tests__/shouldShowUpdateMetadataWarning.test.ts b/static/js/publisher-pages/utils/__tests__/shouldShowUpdateMetadataWarning.test.ts similarity index 100% rename from static/js/publisher/listing/utils/__tests__/shouldShowUpdateMetadataWarning.test.ts rename to static/js/publisher-pages/utils/__tests__/shouldShowUpdateMetadataWarning.test.ts diff --git a/static/js/publisher/listing/utils/__tests__/validateImageDimensions.test.ts b/static/js/publisher-pages/utils/__tests__/validateImageDimensions.test.ts similarity index 100% rename from static/js/publisher/listing/utils/__tests__/validateImageDimensions.test.ts rename to static/js/publisher-pages/utils/__tests__/validateImageDimensions.test.ts diff --git a/static/js/publisher/listing/utils/addDateToFilename.ts b/static/js/publisher-pages/utils/addDateToFilename.ts similarity index 100% rename from static/js/publisher/listing/utils/addDateToFilename.ts rename to static/js/publisher-pages/utils/addDateToFilename.ts diff --git a/static/js/publisher/listing/utils/formatImageChanges.ts b/static/js/publisher-pages/utils/formatImageChanges.ts similarity index 100% rename from static/js/publisher/listing/utils/formatImageChanges.ts rename to static/js/publisher-pages/utils/formatImageChanges.ts diff --git a/static/js/publisher/listing/utils/getDefaultData.ts b/static/js/publisher-pages/utils/getDefaultListingData.ts similarity index 92% rename from static/js/publisher/listing/utils/getDefaultData.ts rename to static/js/publisher-pages/utils/getDefaultListingData.ts index cfeda5a70f..937cf9a180 100644 --- a/static/js/publisher/listing/utils/getDefaultData.ts +++ b/static/js/publisher-pages/utils/getDefaultListingData.ts @@ -1,4 +1,4 @@ -import type { Data } from "../types"; +import type { ListingData } from "../types"; function getPublicMetricsTerritoriesValue( publicMetricsBlacklist: string[] @@ -36,7 +36,9 @@ function getPublicMetricsDistrosValue( return false; } -export default function getDefaultData(data: Data): { [key: string]: any } { +export default function getDefaultListingData(data: ListingData): { + [key: string]: any; +} { return { contacts: data.contacts, description: data.description, diff --git a/static/js/publisher/listing/utils/getChanges.ts b/static/js/publisher-pages/utils/getListingChanges.ts similarity index 96% rename from static/js/publisher/listing/utils/getChanges.ts rename to static/js/publisher-pages/utils/getListingChanges.ts index bde5d68448..7b34725290 100644 --- a/static/js/publisher/listing/utils/getChanges.ts +++ b/static/js/publisher-pages/utils/getListingChanges.ts @@ -1,11 +1,11 @@ import formatImageChanges from "./formatImageChanges"; -import type { Data } from "../types"; +import type { ListingData } from "../types"; -export default function getChanges( +export default function getListingChanges( dirtyFields: any, fieldValues: any, - data: Data + data: ListingData ): { [key: string]: any } { const changes: { [key: string]: any } = {}; diff --git a/static/js/publisher-pages/utils/getChanges.ts b/static/js/publisher-pages/utils/getSettingsChanges.ts similarity index 92% rename from static/js/publisher-pages/utils/getChanges.ts rename to static/js/publisher-pages/utils/getSettingsChanges.ts index 8625ce7e37..26fd5ad7b4 100644 --- a/static/js/publisher-pages/utils/getChanges.ts +++ b/static/js/publisher-pages/utils/getSettingsChanges.ts @@ -1,4 +1,4 @@ -function getChanges(dirtyFields: { [key: string]: any }, data: any) { +function getSettingsChanges(dirtyFields: { [key: string]: any }, data: any) { const changes: { [key: string]: any } = {}; if (dirtyFields?.visibility) { @@ -58,4 +58,4 @@ function getChanges(dirtyFields: { [key: string]: any }, data: any) { return changes; } -export default getChanges; +export default getSettingsChanges; diff --git a/static/js/publisher-pages/utils/getFormData.ts b/static/js/publisher-pages/utils/getSettingsFormData.ts similarity index 90% rename from static/js/publisher-pages/utils/getFormData.ts rename to static/js/publisher-pages/utils/getSettingsFormData.ts index 235b563a79..c2a70dc06a 100644 --- a/static/js/publisher-pages/utils/getFormData.ts +++ b/static/js/publisher-pages/utils/getSettingsFormData.ts @@ -1,13 +1,13 @@ -import getChanges from "./getChanges"; +import getSettingsChanges from "./getSettingsChanges"; import type { SettingsData } from "../types"; -function getFormData( +function getSettingsFormData( settingsData: SettingsData, dirtyFields: { [key: string]: any }, data: any ) { - const changes = getChanges(dirtyFields, data); + const changes = getSettingsChanges(dirtyFields, data); const formData = new FormData(); formData.set("csrf_token", window.CSRF_TOKEN); @@ -51,4 +51,4 @@ function getFormData( return formData; } -export default getFormData; +export default getSettingsFormData; diff --git a/static/js/publisher-pages/utils/index.ts b/static/js/publisher-pages/utils/index.ts index 0deb89bf75..6831b29c2a 100644 --- a/static/js/publisher-pages/utils/index.ts +++ b/static/js/publisher-pages/utils/index.ts @@ -1,5 +1,21 @@ import getSettingsData from "./getSettingsData"; -import getChanges from "./getChanges"; -import getFormData from "./getFormData"; +import getSettingsChanges from "./getSettingsChanges"; +import getSettingsFormData from "./getSettingsFormData"; +import getListingChanges from "./getListingChanges"; +import formatImageChanges from "./formatImageChanges"; +import getDefaultListingData from "./getDefaultListingData"; +import shouldShowUpdateMetadataWarning from "./shouldShowUpdateMetadataWarning"; +import validateImageDimensions from "./validateImageDimensions"; +import addDateToFilename from "./addDateToFilename"; -export { getSettingsData, getChanges, getFormData }; +export { + getSettingsData, + getSettingsChanges, + getSettingsFormData, + getListingChanges, + formatImageChanges, + getDefaultListingData, + shouldShowUpdateMetadataWarning, + validateImageDimensions, + addDateToFilename, +}; diff --git a/static/js/publisher/listing/utils/shouldShowUpdateMetadataWarning.ts b/static/js/publisher-pages/utils/shouldShowUpdateMetadataWarning.ts similarity index 100% rename from static/js/publisher/listing/utils/shouldShowUpdateMetadataWarning.ts rename to static/js/publisher-pages/utils/shouldShowUpdateMetadataWarning.ts diff --git a/static/js/publisher/listing/utils/validateImageDimensions.ts b/static/js/publisher-pages/utils/validateImageDimensions.ts similarity index 100% rename from static/js/publisher/listing/utils/validateImageDimensions.ts rename to static/js/publisher-pages/utils/validateImageDimensions.ts diff --git a/static/js/publisher/listing/components/App/__tests__/App.test.tsx b/static/js/publisher/listing/components/App/__tests__/App.test.tsx deleted file mode 100644 index 34c5d13309..0000000000 --- a/static/js/publisher/listing/components/App/__tests__/App.test.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { BrowserRouter } from "react-router-dom"; -import { QueryClient, QueryClientProvider, useQuery } from "react-query"; -import { render, screen } from "@testing-library/react"; -import "@testing-library/jest-dom"; - -import App from "../App"; - -import { mockData } from "../../../test-utils"; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }, - }, -}); - -jest.mock("react-query", () => ({ - ...jest.requireActual("react-query"), - useQuery: jest.fn(), -})); - -jest.mock("react-router-dom", () => ({ - ...jest.requireActual("react-router-dom"), - useParams: () => ({ - snapName: "test-snap", - }), -})); - -const renderComponent = () => { - return render( - - - - - - ); -}; - -describe("App", () => { - test("renders page header", () => { - // @ts-ignore - useQuery.mockReturnValue({ - isLoading: true, - data: undefined, - refetch: jest.fn(), - }); - - renderComponent(); - - expect( - screen.getByRole("heading", { level: 1, name: "test-snap" }) - ).toBeInTheDocument(); - }); - - test("renders loading state", () => { - // @ts-ignore - useQuery.mockReturnValue({ - isLoading: true, - data: undefined, - refetch: jest.fn(), - }); - - renderComponent(); - - expect( - screen.getByText(/Loading test-snap listing data/) - ).toBeInTheDocument(); - }); - - test("renders listing details section", () => { - // @ts-ignore - useQuery.mockReturnValue({ - isLoading: false, - data: mockData, - refetch: jest.fn(), - }); - - renderComponent(); - - expect( - screen.getByRole("heading", { level: 2, name: "Listing details" }) - ).toBeInTheDocument(); - }); - - test("renders contact section", () => { - // @ts-ignore - useQuery.mockReturnValue({ - isLoading: false, - data: mockData, - refetch: jest.fn(), - }); - - renderComponent(); - - expect( - screen.getByRole("heading", { level: 2, name: "Contact information" }) - ).toBeInTheDocument(); - }); - - test("renders additional information section", () => { - // @ts-ignore - useQuery.mockReturnValue({ - isLoading: false, - data: mockData, - refetch: jest.fn(), - }); - - renderComponent(); - - expect( - screen.getByRole("heading", { level: 2, name: "Additional information" }) - ).toBeInTheDocument(); - }); - - test("shows 'Update metadata' notification", () => { - // @ts-ignore - useQuery.mockReturnValue({ - isLoading: false, - data: { ...mockData, update_metadata_on_release: true }, - refetch: jest.fn(), - }); - - renderComponent(); - - expect( - screen.getByText( - /Information here was automatically updated to the latest version of the snapcraft.yaml released to the stable channel/ - ) - ).toBeInTheDocument(); - }); - - test("doesn't show 'Update metadata' nofitication", () => { - // @ts-ignore - useQuery.mockReturnValue({ - isLoading: false, - data: mockData, - refetch: jest.fn(), - }); - - renderComponent(); - - expect( - screen.queryByText( - /Information here was automatically updated to the latest version of the snapcraft.yaml released to the stable channel/ - ) - ).not.toBeInTheDocument(); - }); -}); diff --git a/static/js/publisher/listing/components/App/index.ts b/static/js/publisher/listing/components/App/index.ts deleted file mode 100644 index 8ce017e646..0000000000 --- a/static/js/publisher/listing/components/App/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./App"; diff --git a/static/js/publisher/listing/hooks/index.ts b/static/js/publisher/listing/hooks/index.ts deleted file mode 100644 index bd59f1cd25..0000000000 --- a/static/js/publisher/listing/hooks/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import useVerified from "./useVerified"; -import useMutateListingData from "./useMutateListingData"; - -export { useVerified, useMutateListingData }; diff --git a/static/js/publisher/listing/index.tsx b/static/js/publisher/listing/index.tsx deleted file mode 100644 index f0ebc90826..0000000000 --- a/static/js/publisher/listing/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { createRoot } from "react-dom/client"; -import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; -import { QueryClient, QueryClientProvider } from "react-query"; -import App from "./components/App"; - -const queryClient = new QueryClient(); - -const container = document.getElementById("main-content"); -const root = createRoot(container as HTMLElement); -root.render( - - - - } /> - - - -); diff --git a/static/js/publisher/listing/test-utils/index.ts b/static/js/publisher/listing/test-utils/index.ts deleted file mode 100644 index 5da2429913..0000000000 --- a/static/js/publisher/listing/test-utils/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { mockData } from "./mockData"; - -export { mockData }; diff --git a/static/js/publisher/listing/types/index.d.ts b/static/js/publisher/listing/types/index.d.ts deleted file mode 100644 index 37cd8c4e13..0000000000 --- a/static/js/publisher/listing/types/index.d.ts +++ /dev/null @@ -1,45 +0,0 @@ -declare global { - interface Window { - SENTRY_DSN: string; - CSRF_TOKEN: string; - listingData: any; - tourSteps: any; - } -} - -export type Step = { - id: string; - position?: string; - elements?: HTMLElement[]; - title: string; - content: string; -}; - -export type Data = { - snap_id: string; - title: string; - video_urls: string; - summary: string; - description: string; - categories: { name: string; slug: string }[]; - primary_category: string; - secondary_category: string; - websites: { url: string }[]; - contacts: { url: string }[]; - donations: { url: string }[]; - source_code: { url: string }[]; - issues: { url: string }[]; - primary_website: string; - public_metrics_enabled: boolean; - public_metrics_blacklist: string[]; - public_metrics_territories: boolean; - public_metrics_distros: boolean; - license: string; - license_type: string; - licenses: { key: string; name: string }[]; - icon_url: string; - screenshot_urls: string[]; - banner_urls: string[]; - update_metadata_on_release: boolean; - tour_steps: Step[]; -}; diff --git a/static/js/publisher/listing/utils/index.ts b/static/js/publisher/listing/utils/index.ts deleted file mode 100644 index d7a5f6555d..0000000000 --- a/static/js/publisher/listing/utils/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import validateImageDimensions from "./validateImageDimensions"; -import shouldShowUpdateMetadataWarning from "./shouldShowUpdateMetadataWarning"; -import addDateToFilename from "./addDateToFilename"; -import formatImageChanges from "./formatImageChanges"; -import getChanges from "./getChanges"; -import getDefaultData from "./getDefaultData"; - -export { - validateImageDimensions, - shouldShowUpdateMetadataWarning, - addDateToFilename, - formatImageChanges, - getChanges, - getDefaultData, -}; diff --git a/static/js/publisher/tour.tsx b/static/js/publisher/tour.tsx index 0cecdd1fae..c25c7eaf8d 100644 --- a/static/js/publisher/tour.tsx +++ b/static/js/publisher/tour.tsx @@ -4,7 +4,7 @@ import Tour from "./tour/tour"; import { toggleShadowWhenSticky } from "./market/stickyListingBar"; -import type { Step } from "./listing/types"; +import type { TourStep } from "../publisher-pages/types"; // returns true if % of truthy values in the array is above the threshold function isCompleted(fields: unknown[], threshold = 0.5): boolean { @@ -21,7 +21,7 @@ export function initTour({ startTour, }: { container: HTMLElement; - steps: Step[]; + steps: TourStep[]; onTourStarted: () => void; onTourClosed: () => void; startTour: boolean; @@ -52,7 +52,7 @@ export function initListingTour({ }: { snapName: string; container: HTMLElement; - steps: Step[]; + steps: TourStep[]; formFields: { title: string; snap_name: string; diff --git a/static/js/publisher/tour/helpers.ts b/static/js/publisher/tour/helpers.ts index 5b9b36a4b8..36a82c5601 100644 --- a/static/js/publisher/tour/helpers.ts +++ b/static/js/publisher/tour/helpers.ts @@ -1,6 +1,6 @@ import { MASK_OFFSET } from "./constants"; -import type { Step } from "../listing/types"; +import type { TourStep } from "../../publisher-pages/types"; // check if element is part of the DOM and is visible export const isVisibleInDocument = (el: HTMLElement): boolean => @@ -8,7 +8,7 @@ export const isVisibleInDocument = (el: HTMLElement): boolean => // find DOM elements for each step, ignore steps with no elements // set default position to "bottom-left" -export function prepareSteps(steps: Step[]): Array<{ +export function prepareSteps(steps: TourStep[]): Array<{ id: string; position: string; elements: HTMLElement[]; diff --git a/static/js/publisher/tour/tour.tsx b/static/js/publisher/tour/tour.tsx index 6978f02aa7..bf7d8fc4b6 100644 --- a/static/js/publisher/tour/tour.tsx +++ b/static/js/publisher/tour/tour.tsx @@ -5,10 +5,10 @@ import TourBar from "./tourBar"; import { tourStartedAutomatically } from "./metricsEvents"; -import type { Step } from "../listing/types"; +import type { TourStep } from "../../publisher-pages/types"; type Props = { - steps: Step[]; + steps: TourStep[]; startTour: boolean; onTourStarted: () => void; onTourClosed: () => void; diff --git a/static/js/publisher/tour/tourBar.tsx b/static/js/publisher/tour/tourBar.tsx index d0b30bca1e..a76cdcf3b7 100644 --- a/static/js/publisher/tour/tourBar.tsx +++ b/static/js/publisher/tour/tourBar.tsx @@ -8,15 +8,13 @@ export default function TourBar({ showTour }: { showTour: () => void }) { return (
-
- -
+
); } diff --git a/static/js/publisher/tour/tourOverlay.tsx b/static/js/publisher/tour/tourOverlay.tsx index c9ddd2b165..2ffebd473a 100644 --- a/static/js/publisher/tour/tourOverlay.tsx +++ b/static/js/publisher/tour/tourOverlay.tsx @@ -14,14 +14,14 @@ import { import { animateScrollTo } from "../../public/scroll-to"; -import type { Step } from "../listing/types"; +import type { TourStep } from "../../publisher-pages/types"; export default function TourOverlay({ steps, hideTour, currentStepIndex = 0, }: { - steps: Step[]; + steps: TourStep[]; hideTour: () => void; currentStepIndex?: number; }) { diff --git a/static/js/publisher/tour/tourStepCard.tsx b/static/js/publisher/tour/tourStepCard.tsx index 2b4865bac2..7c8cb9c188 100644 --- a/static/js/publisher/tour/tourStepCard.tsx +++ b/static/js/publisher/tour/tourStepCard.tsx @@ -1,9 +1,9 @@ import { ReactNode } from "react"; -import type { Step } from "../listing/types"; +import type { TourStep } from "../../publisher-pages/types"; type Props = { - steps: Step[]; + steps: TourStep[]; currentStepIndex: number; mask: { top: number; diff --git a/static/sass/_snapcraft_tour.scss b/static/sass/_snapcraft_tour.scss index 6d7261c463..1b30fd0edc 100644 --- a/static/sass/_snapcraft_tour.scss +++ b/static/sass/_snapcraft_tour.scss @@ -11,7 +11,7 @@ .p-tour-bar { bottom: 0; left: 0; - padding: $spv--medium; + padding: $spv--medium $spv--x-large; pointer-events: none; position: fixed; right: 0; diff --git a/static/sass/styles.scss b/static/sass/styles.scss index e272a87f0a..bcdfcc0c66 100644 --- a/static/sass/styles.scss +++ b/static/sass/styles.scss @@ -562,3 +562,7 @@ dl { flex-grow: 1; } } + +.publisher-app > .p-panel > .p-panel__content { + overflow: visible; +} diff --git a/templates/publisher/listing.html b/templates/publisher/listing.html deleted file mode 100644 index 27a6e41595..0000000000 --- a/templates/publisher/listing.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "publisher/_publisher_layout.html" %} - -{% block meta_title %} -Listing details for {{ snap_name }} -{% endblock %} - -{% block content %} -
- - -{% endblock %} diff --git a/templates/store/publisher.html b/templates/store/publisher.html index 95b7c4c29a..40d63e6288 100644 --- a/templates/store/publisher.html +++ b/templates/store/publisher.html @@ -28,6 +28,10 @@ blacklist_countries: {% if blacklist_country_codes %}{{ blacklist_country_codes|tojson }}{% else %}[]{% endif %}, visibility_locked: {% if visibility_locked %}{{ visibility_locked|tojson }}{% else %}false{% endif %}, } + + window.SNAP_LISTING_DATA = { + DNS_VERIFICATION_TOKEN: "{{ dns_verification_token }}", + }; {% endblock %} diff --git a/webapp/publisher/snaps/listing_views.py b/webapp/publisher/snaps/listing_views.py index 38dde97413..1ce3a6bb83 100644 --- a/webapp/publisher/snaps/listing_views.py +++ b/webapp/publisher/snaps/listing_views.py @@ -166,7 +166,7 @@ def get_listing_snap(snap_name): snap_details["snap_name"], snap_details["links"]["website"][0] ) return flask.render_template( - "publisher/listing.html", + "store/publisher.html", snap_name=snap_name, dns_verification_token=token, ) diff --git a/webpack.config.entry.js b/webpack.config.entry.js index 0bdcf04789..edf1d75f05 100644 --- a/webpack.config.entry.js +++ b/webpack.config.entry.js @@ -17,7 +17,6 @@ module.exports = { "distro-install": "./static/js/public/distro-install.ts", "publisher-details": "./static/js/public/publisher-details.ts", "brand-store": "./static/js/brand-store/brand-store.tsx", - "publisher-listing": "./static/js/publisher/listing/index.tsx", "about-listing": "./static/js/public/about/listing.ts", store: "./static/js/store/index.tsx", "publisher-pages": "./static/js/publisher-pages/index.tsx",