From 55cb6b2ebfbbe87fabda1bce8a93c5ffb6555345 Mon Sep 17 00:00:00 2001 From: Steve Rydz Date: Mon, 9 Sep 2024 12:22:11 +0100 Subject: [PATCH 01/10] 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 231af98f31b678e8a10aeae90a31e991cd245c91 Mon Sep 17 00:00:00 2001 From: Steve Rydz Date: Tue, 10 Sep 2024 11:14:56 +0100 Subject: [PATCH 02/10] 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 359fa7a54b..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 1ca12f407b..2ca923fc66 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 67dee8644679f38b24c9bd7dba55aaf46a346741 Mon Sep 17 00:00:00 2001 From: ilayda-cp Date: Fri, 20 Sep 2024 12:59:35 +0300 Subject: [PATCH 03/10] 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 45da910b99..abd213be39 100644 --- a/webapp/publisher/snaps/views.py +++ b/webapp/publisher/snaps/views.py @@ -218,6 +218,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 edeb11d0661c8cd43e4d92607d3a11a0e1b0324b Mon Sep 17 00:00:00 2001 From: Ilayda Cavusoglu Pars Date: Wed, 25 Sep 2024 16:51:11 +0300 Subject: [PATCH 04/10] feat: fetch metric data for smaller periods --- .../hooks/useActiveDeviceMetrics.ts | 192 ++++++++++++++++-- .../pages/Metrics/ActiveDeviceMetrics.tsx | 26 ++- webapp/metrics/helper.py | 30 +++ webapp/publisher/snaps/metrics_views.py | 63 ++++-- webapp/publisher/snaps/views.py | 5 + 5 files changed, 274 insertions(+), 42 deletions(-) diff --git a/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts b/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts index 3bac68434e..c640a21b91 100644 --- a/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts +++ b/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts @@ -1,5 +1,16 @@ +import { useEffect, useState } from "react"; import { useQuery } from "react-query"; +export interface IActiveDevices { + activeDevices: { + buckets: string[]; + name: string; + series: any[]; + }; +} + +const MAX_PERIOD_BY_MONTH = 3; + function useActiveDeviceMetrics({ snapId, period, @@ -9,32 +20,173 @@ function useActiveDeviceMetrics({ period: string; type: string; }) { - return useQuery({ - queryKey: ["activeDeviceMetrics", snapId, period, type], - queryFn: async () => { + const parsePeriod = (period: string) => { + const [_, periodNumber, periodLength] = period.trim().split(/(\d+)/); + return { periodNumber: +periodNumber, periodLength }; + }; + + const getStartDate = (endDate: Date, period: string) => { + const periodRegExp = /^(\d+)(d|m|y)$/; + + if (!period || !periodRegExp.test(period)) { + return new Date(endDate.setDate(endDate.getDate() - 30)); + } + + const { periodNumber, periodLength } = parsePeriod(period); + + if (periodLength === "d") { + return new Date(endDate.setDate(endDate.getDate() - +periodNumber)); + } else if (periodLength === "m") { + return new Date(endDate.setMonth(endDate.getMonth() - +periodNumber)); + } else { + // periodLength is 'y' + return new Date( + endDate.setFullYear(endDate.getFullYear() - +periodNumber) + ); + } + }; + + // const [endDate, setEndDate] = useState(() => { + // return getEndDate(new Date(), `${MAX_PERIOD_BY_MONTH}m`); + // }); + + // const { status, data, isFetching } = useQuery({ + // queryKey: ["activeDeviceMetrics", snapId, period, type], + // queryFn: async () => { + // const response = await fetch( + // `/${snapId}/metrics/active-devices?active-devices=${type}&start=${startDate.toISOString().split("T")[0]}&end=${endDate.toISOString().split("T")[0]}` + // ); + + // 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, + // }); + + const [fetchedData, setFetchedData] = useState( + undefined + ); + + const fetchData = async () => { + // const period = "1y"; + let endDate = new Date(); + const { periodLength, periodNumber } = parsePeriod(period); + if ( + periodLength === "d" || + (periodLength === "m" && MAX_PERIOD_BY_MONTH >= periodNumber) + ) { + // no need to paginate + const startDate = getStartDate(new Date(), period); const response = await fetch( - `/${snapId}/metrics/active-devices?period=${period}&active-devices=${type}` + `/${snapId}/metrics/active-devices?active-devices=${type}&start=${startDate.toISOString().split("T")[0]}&end=${endDate.toISOString().split("T")[0]}` ); + console.log(response); + } else { + // pagiante + const numberOfIterations = + periodLength === "y" + ? Math.floor((periodNumber * 12) / MAX_PERIOD_BY_MONTH) + : Math.floor(periodNumber / MAX_PERIOD_BY_MONTH); - 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"); + const responses = []; + let extraDay = 0; + for (let i = 0; i < numberOfIterations; i++) { + const startDate = getStartDate( + new Date(endDate), + `${MAX_PERIOD_BY_MONTH}m` + ); + const endDateForRequest = new Date( + endDate.setDate(endDate.getDate() - extraDay) + ); + console.log( + startDate.toISOString().split("T")[0], + endDateForRequest.toISOString().split("T")[0] + ); + + responses.push( + fetch( + `/${snapId}/metrics/active-devices?active-devices=${type}&start=${startDate.toISOString().split("T")[0]}&end=${endDateForRequest.toISOString().split("T")[0]}` + ) + ); + endDate = new Date(startDate); + extraDay = 1; + } + + const results = await Promise.all(responses); + + const buckets = []; + const series = new Map(); + + let seriesThatAreAddedBefore = 0; + + for (const result of results.reverse()) { + const data = await result.json(); + console.log(data); + + const activeDeviceBuckets = data.active_devices.buckets; + + // merge data + buckets.push(...data.active_devices.buckets); + // fill the arr with 0's if the batch doesnt have that previous series + for (const seriesKey of series.keys()) { + if ( + !data.active_devices.series.find( + (activeDeviceSeries: { name: string }) => + activeDeviceSeries.name === seriesKey + ) + ) { + series.set(seriesKey, [ + ...series.get(seriesKey), + ...new Array(activeDeviceBuckets.length).fill(0), + ]); + } + } + + 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; } - return await response.json(); - }, - retry: 0, - refetchOnWindowFocus: false, - }); + const resultArray = Array.from(series.entries()).map(([key, value]) => ({ + name: key, + values: value, + })); + + setFetchedData({ + activeDevices: { + buckets, + name: "", + series: resultArray, + }, + }); + } + }; + useEffect(() => { + fetchData(); + }, []); + + return { status: "success", data: fetchedData, isFetching: false }; } export default useActiveDeviceMetrics; diff --git a/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx b/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx index 9067f18c13..5fcce719d9 100644 --- a/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx +++ b/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx @@ -33,22 +33,30 @@ 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]); + const fetchData = async () => { + const data = await fetch(`/${snapId}/metrics/active-latest-devices`); + const d = await data.json(); + const activeDevices = d.latest_active_devices; + activeDevices && + setLatestActiveDevices( + String(activeDevices).replace(/(.)(?=(\d{3})+$)/g, "$1,") + ); + }; + + useEffect(() => { + void fetchData(); + }, []); + const onChange = (key: string, value: string) => { // clear the chart const svg = select(`${selector} svg`); diff --git a/webapp/metrics/helper.py b/webapp/metrics/helper.py index 4700ea980b..293864d90d 100644 --- a/webapp/metrics/helper.py +++ b/webapp/metrics/helper.py @@ -46,6 +46,36 @@ def build_metric_query_installed_base( years=-metric_period, days=-1 ) + print("Start: ", start, " - End: ", end) + + return { + "filters": [ + get_filter( + metric_name=installed_base, + snap_id=snap_id, + start=start, + end=end, + ), + ] + } + + +def build_metric_query_installed_base_new( + snap_id, installed_base, end, start +): + + # 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 { "filters": [ get_filter( diff --git a/webapp/publisher/snaps/metrics_views.py b/webapp/publisher/snaps/metrics_views.py index 29ec00021b..d1765e3447 100644 --- a/webapp/publisher/snaps/metrics_views.py +++ b/webapp/publisher/snaps/metrics_views.py @@ -15,6 +15,10 @@ from webapp.decorators import login_required from webapp.publisher.snaps import logic +import time +from dateutil import relativedelta +from datetime import datetime + publisher_api = SnapPublisher(api_publisher_session) store_api = SnapStore(api_publisher_session) @@ -71,27 +75,43 @@ 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_start = flask.request.args.get("start", type=str) + period_end = flask.request.args.get("end", type=str) + + if period_end is None: + end_date = metrics_helper.get_last_metrics_processed_date() + else: + date_format = '%Y-%m-%d' + end_date = datetime.strptime(period_end, date_format) + + + if period_start is None: + start_date = end_date + relativedelta.relativedelta(months=-1) + else: + date_format = '%Y-%m-%d' + start_date = datetime.strptime(period_start, date_format) + installed_base = logic.get_installed_based_metric(installed_base_metric) - metrics_query_json = metrics_helper.build_metric_query_installed_base( + + new_metrics_query = metrics_helper.build_metric_query_installed_base_new( snap_id=snap_id, installed_base=installed_base, - metric_period=metric_requested["int"], - metric_bucket=metric_requested["bucket"], + end=end_date, + start=start_date ) - + metrics_response = publisher_api.get_publisher_metrics( - flask.session, json=metrics_query_json + flask.session, json=new_metrics_query ) + start = time.time() active_metrics = metrics_helper.find_metric( metrics_response["metrics"], installed_base ) @@ -115,6 +135,25 @@ def get_active_devices(snap_name): status=active_metrics["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, + } + ) + + +@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 +163,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,14 +184,12 @@ 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, } ) - + @login_required def get_metric_annotaion(snap_name): diff --git a/webapp/publisher/snaps/views.py b/webapp/publisher/snaps/views.py index abd213be39..487a1c8c33 100644 --- a/webapp/publisher/snaps/views.py +++ b/webapp/publisher/snaps/views.py @@ -223,6 +223,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 4f41a98ade15f3947774104296d8c5e03a72240d Mon Sep 17 00:00:00 2001 From: Ilayda Cavusoglu Pars Date: Thu, 26 Sep 2024 13:49:05 +0300 Subject: [PATCH 05/10] feat: fetch metric data for smaller periods --- .../publisher-pages/hooks/useActiveDeviceMetrics.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts b/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts index c640a21b91..f64c69e2e6 100644 --- a/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts +++ b/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts @@ -94,7 +94,14 @@ function useActiveDeviceMetrics({ const response = await fetch( `/${snapId}/metrics/active-devices?active-devices=${type}&start=${startDate.toISOString().split("T")[0]}&end=${endDate.toISOString().split("T")[0]}` ); - console.log(response); + const data = await response.json(); + setFetchedData({ + activeDevices: { + buckets: data.active_devices.buckets, + name: data.active_devices.name, + series: data.active_devices.series, + }, + }); } else { // pagiante const numberOfIterations = @@ -135,7 +142,6 @@ function useActiveDeviceMetrics({ for (const result of results.reverse()) { const data = await result.json(); - console.log(data); const activeDeviceBuckets = data.active_devices.buckets; @@ -173,6 +179,8 @@ function useActiveDeviceMetrics({ values: value, })); + console.log(resultArray); + setFetchedData({ activeDevices: { buckets, @@ -182,6 +190,7 @@ function useActiveDeviceMetrics({ }); } }; + useEffect(() => { fetchData(); }, []); From f0fd1ddb76de18acd45ad25f75c56f9eb2f9295a Mon Sep 17 00:00:00 2001 From: Ilayda Cavusoglu Pars Date: Fri, 27 Sep 2024 13:24:56 +0300 Subject: [PATCH 06/10] feat: added downsampling --- .../hooks/useActiveDeviceMetrics.ts | 156 +++++++---------- .../pages/Metrics/ActiveDeviceMetrics.tsx | 56 +++--- webapp/metrics/helper.py | 2 - webapp/publisher/snaps/metrics_views.py | 163 +++++++++++++++--- 4 files changed, 232 insertions(+), 145 deletions(-) diff --git a/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts b/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts index f64c69e2e6..41d32f0007 100644 --- a/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts +++ b/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts @@ -82,113 +82,75 @@ function useActiveDeviceMetrics({ ); const fetchData = async () => { - // const period = "1y"; - let endDate = new Date(); - const { periodLength, periodNumber } = parsePeriod(period); - if ( - periodLength === "d" || - (periodLength === "m" && MAX_PERIOD_BY_MONTH >= periodNumber) - ) { - // no need to paginate - const startDate = getStartDate(new Date(), period); - const response = await fetch( - `/${snapId}/metrics/active-devices?active-devices=${type}&start=${startDate.toISOString().split("T")[0]}&end=${endDate.toISOString().split("T")[0]}` + const response = await fetch( + `/${snapId}/metrics/active-devices?active-devices=${type}&period=2y&page=${1}` + ); + + const data = await response.json(); + + const responses = []; + for (let i = 2; i <= data.total_page_num; i++) { + responses.push( + fetch( + `/${snapId}/metrics/active-devices?active-devices=${type}&period=1y&page=${i}` + ) ); - const data = await response.json(); - setFetchedData({ - activeDevices: { - buckets: data.active_devices.buckets, - name: data.active_devices.name, - series: data.active_devices.series, - }, - }); - } else { - // pagiante - const numberOfIterations = - periodLength === "y" - ? Math.floor((periodNumber * 12) / MAX_PERIOD_BY_MONTH) - : Math.floor(periodNumber / MAX_PERIOD_BY_MONTH); - - const responses = []; - let extraDay = 0; - for (let i = 0; i < numberOfIterations; i++) { - const startDate = getStartDate( - new Date(endDate), - `${MAX_PERIOD_BY_MONTH}m` - ); - const endDateForRequest = new Date( - endDate.setDate(endDate.getDate() - extraDay) - ); - console.log( - startDate.toISOString().split("T")[0], - endDateForRequest.toISOString().split("T")[0] - ); - - responses.push( - fetch( - `/${snapId}/metrics/active-devices?active-devices=${type}&start=${startDate.toISOString().split("T")[0]}&end=${endDateForRequest.toISOString().split("T")[0]}` - ) - ); - endDate = new Date(startDate); - extraDay = 1; - } + } - const results = await Promise.all(responses); - - const buckets = []; - const series = new Map(); - - let seriesThatAreAddedBefore = 0; - - for (const result of results.reverse()) { - const data = await result.json(); - - const activeDeviceBuckets = data.active_devices.buckets; - - // merge data - buckets.push(...data.active_devices.buckets); - // fill the arr with 0's if the batch doesnt have that previous series - for (const seriesKey of series.keys()) { - if ( - !data.active_devices.series.find( - (activeDeviceSeries: { name: string }) => - activeDeviceSeries.name === seriesKey - ) - ) { - series.set(seriesKey, [ - ...series.get(seriesKey), - ...new Array(activeDeviceBuckets.length).fill(0), - ]); - } - } + const results = await Promise.all(responses); - 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); + const buckets = []; + const series = new Map(); - series.set(key, [...prevData, ...activeDeviceSeries.values]); - } + let seriesThatAreAddedBefore = 0; - seriesThatAreAddedBefore += activeDeviceBuckets.length; + for (const result of results.reverse()) { + const data = await result.json(); + + const activeDeviceBuckets = data.active_devices.buckets; + + // merge data + buckets.push(...data.active_devices.buckets); + // fill the arr with 0's if the batch doesnt have that previous series + for (const seriesKey of series.keys()) { + if ( + !data.active_devices.series.find( + (activeDeviceSeries: { name: string }) => + activeDeviceSeries.name === seriesKey + ) + ) { + series.set(seriesKey, [ + ...series.get(seriesKey), + ...new Array(activeDeviceBuckets.length).fill(0), + ]); + } } - const resultArray = Array.from(series.entries()).map(([key, value]) => ({ - name: key, - values: value, - })); + 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); - console.log(resultArray); + series.set(key, [...prevData, ...activeDeviceSeries.values]); + } - setFetchedData({ - activeDevices: { - buckets, - name: "", - series: resultArray, - }, - }); + seriesThatAreAddedBefore += activeDeviceBuckets.length; } + + const resultArray = Array.from(series.entries()).map(([key, value]) => ({ + name: key, + values: value, + })); + + setFetchedData({ + activeDevices: { + buckets, + name: "", + series: resultArray, + }, + }); + // } }; useEffect(() => { diff --git a/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx b/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx index 5fcce719d9..9f8b7e69d8 100644 --- a/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx +++ b/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx @@ -23,13 +23,19 @@ function ActiveDeviceMetrics({ const period = searchParams.get("period") ?? "30d"; const type = searchParams.get("active-devices") ?? "version"; + const selector = "#activeDevices"; - const { status, data, isFetching } = useActiveDeviceMetrics({ + const { data } = useActiveDeviceMetrics({ snapId, period, type, }); + // const { status, data, isFetching } = useActiveDeviceMetrics({ + // snapId, + // period, + // type, + // }); useEffect(() => { if (data) { @@ -80,29 +86,31 @@ function ActiveDeviceMetrics({
- {isFetching ? ( - - ) : ( - <> - - {isEmpty &&
No data found.
} - {status === "error" && ( - An error occurred. Please try again., - wrapLines: true, - }, - ]} - /> - )} - - )} + { + // isFetching ? ( + // + // ) : ( + // <> + // + // {isEmpty &&
No data found.
} + // {status === "error" && ( + // An error occurred. Please try again., + // wrapLines: true, + // }, + // ]} + // /> + // )} + // + // ) + }
diff --git a/webapp/metrics/helper.py b/webapp/metrics/helper.py index 293864d90d..9c93b5e029 100644 --- a/webapp/metrics/helper.py +++ b/webapp/metrics/helper.py @@ -46,8 +46,6 @@ def build_metric_query_installed_base( years=-metric_period, days=-1 ) - print("Start: ", start, " - End: ", end) - return { "filters": [ get_filter( diff --git a/webapp/publisher/snaps/metrics_views.py b/webapp/publisher/snaps/metrics_views.py index d1765e3447..e8da80cf52 100644 --- a/webapp/publisher/snaps/metrics_views.py +++ b/webapp/publisher/snaps/metrics_views.py @@ -18,6 +18,7 @@ import time from dateutil import relativedelta from datetime import datetime +import math publisher_api = SnapPublisher(api_publisher_session) store_api = SnapStore(api_publisher_session) @@ -70,8 +71,87 @@ def publisher_snap_metrics(snap_name): return flask.render_template("publisher/metrics.html", **context) +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): + # Ensure next_bucket_start doesn't exceed n - 1 + next_bucket_start = min(math.ceil((i + 1) * bucket_size), n - 1) + + max_area = 0 + max_area_idx = current_bucket_start + + # Get point1 and point2, treating None as 0 + 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) + + # Calculate the area for each valid index between current and next bucket + 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) # Always keep the last point + return indices + +def normalize_series(series): + """Ensure all value arrays in the series have the same size by padding shorter arrays with None.""" + max_length = max(len(item['values']) for item in series) + + for item in series: + values = item['values'] + # Extend the values with None if they are shorter than the max length + if len(values) < max_length: + values.extend([None] * (max_length - len(values))) + +def downsample_series(buckets, series, target_size): + """Downsample each series in the data, treating None as 0.""" + downsampled_buckets = [] + downsampled_series = [] + + # Normalize series first to make sure all series have the same length + normalize_series(series) + + # Downsample each series independently + for item in series: + name = item['name'] + values = item['values'] + + # Get the LTTB-selected indices + selected_indices = lttb_select_indices(values, target_size) + + # Collect the downsampled 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 + @login_required def get_active_devices(snap_name): + snap_details = store_api.get_item_details( snap_name, api_version=2, fields=["snap-id"] ) @@ -82,57 +162,88 @@ def get_active_devices(snap_name): flask.request.args.get("active-devices", default="version", type=str) ) - period_start = flask.request.args.get("start", type=str) - period_end = flask.request.args.get("end", type=str) - - if period_end is None: - end_date = metrics_helper.get_last_metrics_processed_date() - else: - date_format = '%Y-%m-%d' - end_date = datetime.strptime(period_end, date_format) + period = flask.request.args.get("period", default="30d", type=str) + active_device_period = logic.extract_metrics_period(period) - - if period_start is None: - start_date = end_date + relativedelta.relativedelta(months=-1) + 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 = 3 + total_page_num = 1 + if metric_requested_bucket == "d" or (metric_requested_bucket == "m" and page_time_length >= metric_requested_length): + end = metrics_helper.get_last_metrics_processed_date() + + if metric_requested_bucket == "d": + start = end + relativedelta.relativedelta(days=-metric_requested_length) + elif metric_requested_bucket == "m": + start = end + relativedelta.relativedelta(months=-metric_requested_length) + elif metric_requested_bucket == "y": + # Go back an extra day to ensure the granularity increases + start = end + relativedelta.relativedelta( + years=-metric_requested_length, days=-1 + ) else: - date_format = '%Y-%m-%d' - start_date = datetime.strptime(period_start, date_format) + if metric_requested_bucket == 'y': + total_page_num = math.floor((metric_requested_length * 12) / page_time_length) + else: + total_page_num = math.floor(metric_requested_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))) + if page != 1: + end = end + relativedelta.relativedelta(days=-1) + + print(start, end, page, total_page_num) installed_base = logic.get_installed_based_metric(installed_base_metric) new_metrics_query = metrics_helper.build_metric_query_installed_base_new( snap_id=snap_id, installed_base=installed_base, - end=end_date, - start=start_date + end=end, + start=start ) metrics_response = publisher_api.get_publisher_metrics( flask.session, json=new_metrics_query ) - start = time.time() active_metrics = metrics_helper.find_metric( metrics_response["metrics"], installed_base ) + # Extract buckets and series + metrics_data = active_metrics + buckets = metrics_data['buckets'] + series = metrics_data['series'] + metric_name = metrics_data['metric_name'] + + # Target size for downsampling + target_size = 100 + + # Perform downsampling + downsampled_buckets, downsampled_series = downsample_series(buckets, series, target_size) + print(len(downsampled_buckets), len(downsampled_series), target_size) + - series = active_metrics["series"] - if active_metrics["metric_name"] == "weekly_installed_base_by_channel": + 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"] + capitalized_series = series for item in capitalized_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 @@ -143,8 +254,16 @@ def get_active_devices(snap_name): { "active_devices": dict(active_devices), "latest_active_devices": latest_active, + "total_page_num": total_page_num } ) + # return flask.jsonify( + # { + # "active_devices": dict({}), + # "latest_active_devices": 0, + # "total_page_num": total_page_num + # } + # ) @login_required From 42d0575b0c12c2730e1c4f10bdd2aea6d0358e2d Mon Sep 17 00:00:00 2001 From: Ilayda Cavusoglu Pars Date: Fri, 27 Sep 2024 14:19:54 +0300 Subject: [PATCH 07/10] feat: added downsampling --- .../hooks/useActiveDeviceMetrics.ts | 26 ++++++++++++++----- webapp/publisher/snaps/metrics_views.py | 4 ++- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts b/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts index 41d32f0007..6a018c0c64 100644 --- a/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts +++ b/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts @@ -82,17 +82,29 @@ function useActiveDeviceMetrics({ ); const fetchData = async () => { - const response = await fetch( - `/${snapId}/metrics/active-devices?active-devices=${type}&period=2y&page=${1}` - ); - - const data = await response.json(); + const periodLength = 5; + const period = "y"; + const pagePeriodLength = 3; + const pagePeriod = "m"; + + let totalPage = 1; + if ( + period === "d" || + (period === "m" && periodLength <= pagePeriodLength) + ) { + totalPage = 1; + } else { + totalPage = + period === "y" + ? Math.floor((periodLength * 12) / pagePeriodLength) + : Math.floor(periodLength / pagePeriodLength); + } const responses = []; - for (let i = 2; i <= data.total_page_num; i++) { + for (let i = 1; i <= totalPage; i++) { responses.push( fetch( - `/${snapId}/metrics/active-devices?active-devices=${type}&period=1y&page=${i}` + `/${snapId}/metrics/active-devices?active-devices=${type}&period=${periodLength}${period}&page=${i}` ) ); } diff --git a/webapp/publisher/snaps/metrics_views.py b/webapp/publisher/snaps/metrics_views.py index e8da80cf52..5e020bac6a 100644 --- a/webapp/publisher/snaps/metrics_views.py +++ b/webapp/publisher/snaps/metrics_views.py @@ -219,8 +219,10 @@ def get_active_devices(snap_name): series = metrics_data['series'] metric_name = metrics_data['metric_name'] + print("bucket size: ", len(buckets), ", series: ", len(series)) + # Target size for downsampling - target_size = 100 + target_size = 20 # Perform downsampling downsampled_buckets, downsampled_series = downsample_series(buckets, series, target_size) From a514bb6bb02f32fec5976c14fab927ee8f245e7e Mon Sep 17 00:00:00 2001 From: Ilayda Cavusoglu Pars Date: Fri, 27 Sep 2024 17:36:03 +0300 Subject: [PATCH 08/10] feat: added downsampling --- .../hooks/useActiveDeviceMetrics.ts | 150 +++++++++++++++--- .../pages/Metrics/ActiveDeviceMetrics.tsx | 53 ++++--- webapp/publisher/snaps/metrics_views.py | 60 +++---- 3 files changed, 186 insertions(+), 77 deletions(-) diff --git a/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts b/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts index 6a018c0c64..0cb001d8b6 100644 --- a/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts +++ b/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useQuery } from "react-query"; export interface IActiveDevices { @@ -77,40 +77,149 @@ function useActiveDeviceMetrics({ // refetchOnWindowFocus: false, // }); + const { status, data, isFetching } = useQuery({ + queryKey: ["activeDeviceMetrics", snapId, period, type], + queryFn: async () => { + // const response = await fetch( + // `/${snapId}/metrics/active-devices?active-devices=${type}&start=${startDate.toISOString().split("T")[0]}&end=${endDate.toISOString().split("T")[0]}` + // ); + + // 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(); + + return await fetchData(); + }, + retry: 0, + refetchOnWindowFocus: false, + }); + const [fetchedData, setFetchedData] = useState( undefined ); + const [responses2, setResponses2] = useState([]); + const fetchData = async () => { const periodLength = 5; const period = "y"; - const pagePeriodLength = 3; - const pagePeriod = "m"; + const pagePeriodLengthInMonths = 12; let totalPage = 1; - if ( - period === "d" || - (period === "m" && periodLength <= pagePeriodLength) - ) { - totalPage = 1; - } else { - totalPage = - period === "y" - ? Math.floor((periodLength * 12) / pagePeriodLength) - : Math.floor(periodLength / pagePeriodLength); - } + // if ( + // period === "d" || + // (period === "m" && periodLength <= pagePeriodLength) + // ) { + // totalPage = 1; + // } else { + totalPage = + period === "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=${periodLength}${period}&page=${i}` + `/${snapId}/metrics/active-devices?active-devices=${type}&period=${periodLength}${period}&page=${i}&page-length=${pagePeriodLengthInMonths}` ) ); } const results = await Promise.all(responses); + return await formatData(results); + // for (let i = 1; i <= totalPage; i++) { + // fetch( + // `/${snapId}/metrics/active-devices?active-devices=${type}&period=${periodLength}${period}&page=${i}` + // ) + // .then((result) => { + // result.json().then((d) => { + // setResponses2((prev) => [...prev, d]); + // }); + // }) + // .catch((e) => console.error(e)); + // } + // } + }; + // useEffect(() => { + // formatData2(responses2); + // }, [responses2]); + + const formatData2 = async (results: any[]) => { + const buckets = []; + const series = new Map(); + + let seriesThatAreAddedBefore = 0; + const sorted = results.sort((a, b) => { + console.log("---sort---"); + console.log(a.active_devices.buckets[0]); + console.log(b.active_devices.buckets[0]); + console.log("----"); + return a.active_devices.buckets[0].localeCompare( + b.active_devices.buckets[0] + ); + }); + for (const data of sorted) { + const activeDeviceBuckets = data.active_devices.buckets; + + // merge data + buckets.push(...data.active_devices.buckets); + // fill the arr with 0's if the batch doesnt have that previous series + for (const seriesKey of series.keys()) { + if ( + !data.active_devices.series.find( + (activeDeviceSeries: { name: string }) => + activeDeviceSeries.name === seriesKey + ) + ) { + series.set(seriesKey, [ + ...series.get(seriesKey), + ...new Array(activeDeviceBuckets.length).fill(0), + ]); + } + } + + 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, + })); + + setFetchedData({ + activeDevices: { + buckets, + name: "", + series: resultArray, + }, + }); + }; + + const formatData = async (results: any[]) => { const buckets = []; const series = new Map(); @@ -155,21 +264,16 @@ function useActiveDeviceMetrics({ values: value, })); - setFetchedData({ + return { activeDevices: { buckets, name: "", series: resultArray, }, - }); - // } + }; }; - useEffect(() => { - fetchData(); - }, []); - - return { status: "success", data: fetchedData, isFetching: false }; + return { status, data, isFetching }; } export default useActiveDeviceMetrics; diff --git a/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx b/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx index 9f8b7e69d8..2dcb511f9f 100644 --- a/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx +++ b/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx @@ -26,7 +26,7 @@ function ActiveDeviceMetrics({ const selector = "#activeDevices"; - const { data } = useActiveDeviceMetrics({ + const { status, data, isFetching } = useActiveDeviceMetrics({ snapId, period, type, @@ -49,6 +49,9 @@ function ActiveDeviceMetrics({ } }, [data]); + useEffect(() => { + console.log(status); + }, [status]); const fetchData = async () => { const data = await fetch(`/${snapId}/metrics/active-latest-devices`); const d = await data.json(); @@ -86,31 +89,29 @@ function ActiveDeviceMetrics({
- { - // isFetching ? ( - // - // ) : ( - // <> - // - // {isEmpty &&
No data found.
} - // {status === "error" && ( - // An error occurred. Please try again.
, - // wrapLines: true, - // }, - // ]} - // /> - // )} - // - // ) - } + {isFetching ? ( + + ) : ( + <> + + {isEmpty &&
No data found.
} + {status === "error" && ( + An error occurred. Please try again., + wrapLines: true, + }, + ]} + /> + )} + + )}
diff --git a/webapp/publisher/snaps/metrics_views.py b/webapp/publisher/snaps/metrics_views.py index 5e020bac6a..2a12f3fcd2 100644 --- a/webapp/publisher/snaps/metrics_views.py +++ b/webapp/publisher/snaps/metrics_views.py @@ -83,13 +83,11 @@ def lttb_select_indices(values, target_size): current_bucket_start = 0 for i in range(1, target_size - 1): - # Ensure next_bucket_start doesn't exceed n - 1 next_bucket_start = min(math.ceil((i + 1) * bucket_size), n - 1) max_area = 0 max_area_idx = current_bucket_start - # Get point1 and point2, treating None as 0 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) @@ -109,33 +107,38 @@ def lttb_select_indices(values, target_size): indices.append(max_area_idx) current_bucket_start = next_bucket_start - indices.append(n - 1) # Always keep the last point + indices.append(n - 1) return indices -def normalize_series(series): - """Ensure all value arrays in the series have the same size by padding shorter arrays with None.""" - max_length = max(len(item['values']) for item in series) - +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'] - # Extend the values with None if they are shorter than the max length - if len(values) < max_length: - values.extend([None] * (max_length - len(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) + normalize_series(series, bucket_count) # Downsample each series independently for item in series: name = item['name'] values = item['values'] - # Get the LTTB-selected indices selected_indices = lttb_select_indices(values, target_size) # Collect the downsampled buckets and values based on the selected indices @@ -170,7 +173,7 @@ def get_active_devices(snap_name): metric_requested_length = active_device_period["int"] metric_requested_bucket = active_device_period["bucket"] - page_time_length = 3 + 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): end = metrics_helper.get_last_metrics_processed_date() @@ -194,8 +197,6 @@ def get_active_devices(snap_name): start = end + (relativedelta.relativedelta(months=-(page_time_length))) if page != 1: end = end + relativedelta.relativedelta(days=-1) - - print(start, end, page, total_page_num) installed_base = logic.get_installed_based_metric(installed_base_metric) @@ -206,9 +207,11 @@ def get_active_devices(snap_name): start=start ) + start_time = time.time() metrics_response = publisher_api.get_publisher_metrics( flask.session, json=new_metrics_query ) + print("--- %s seconds ---" % (time.time() - start_time)) active_metrics = metrics_helper.find_metric( metrics_response["metrics"], installed_base @@ -222,12 +225,20 @@ def get_active_devices(snap_name): print("bucket size: ", len(buckets), ", series: ", len(series)) # Target size for downsampling - target_size = 20 - - # Perform downsampling - downsampled_buckets, downsampled_series = downsample_series(buckets, series, target_size) - print(len(downsampled_buckets), len(downsampled_series), target_size) - + # not sure about the numbers + if len(series) > 100: + start_time = time.time() + downsampled_buckets, downsampled_series = downsample_series(buckets, series, 15) + print("--- %s seconds ---" % (time.time() - start_time)) + print(len(downsampled_buckets), len(downsampled_series), 15) + elif len(series) > 50: + start_time = time.time() + downsampled_buckets, downsampled_series = downsample_series(buckets, series, 30) + print("--- %s seconds ---" % (time.time() - start_time)) + print(len(downsampled_buckets), len(downsampled_series), 30) + else: + downsampled_buckets = buckets + downsampled_series = series series = downsampled_series if metric_name == "weekly_installed_base_by_channel": @@ -259,13 +270,6 @@ def get_active_devices(snap_name): "total_page_num": total_page_num } ) - # return flask.jsonify( - # { - # "active_devices": dict({}), - # "latest_active_devices": 0, - # "total_page_num": total_page_num - # } - # ) @login_required From 91cef30da2d8cafab9fc860f2c15758fdc2b9729 Mon Sep 17 00:00:00 2001 From: Ilayda Cavusoglu Pars Date: Tue, 1 Oct 2024 14:51:16 +0300 Subject: [PATCH 09/10] feat: add downsampling --- .../hooks/useActiveDeviceMetrics.ts | 224 ++++-------------- .../hooks/useLatestActiveDevicesMetric.ts | 26 ++ .../hooks/useMetricsAnnotation.ts | 2 + .../pages/Metrics/ActiveDeviceMetrics.tsx | 27 +-- webapp/metrics/helper.py | 46 ++-- webapp/publisher/snaps/metrics_views.py | 34 +-- 6 files changed, 97 insertions(+), 262 deletions(-) create mode 100644 static/js/publisher-pages/hooks/useLatestActiveDevicesMetric.ts diff --git a/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts b/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts index 0cb001d8b6..bdf475b559 100644 --- a/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts +++ b/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts @@ -9,8 +9,6 @@ export interface IActiveDevices { }; } -const MAX_PERIOD_BY_MONTH = 3; - function useActiveDeviceMetrics({ snapId, period, @@ -20,226 +18,88 @@ function useActiveDeviceMetrics({ period: string; type: string; }) { - const parsePeriod = (period: string) => { - const [_, periodNumber, periodLength] = period.trim().split(/(\d+)/); - return { periodNumber: +periodNumber, periodLength }; - }; - - const getStartDate = (endDate: Date, period: string) => { - const periodRegExp = /^(\d+)(d|m|y)$/; - - if (!period || !periodRegExp.test(period)) { - return new Date(endDate.setDate(endDate.getDate() - 30)); - } - - const { periodNumber, periodLength } = parsePeriod(period); - - if (periodLength === "d") { - return new Date(endDate.setDate(endDate.getDate() - +periodNumber)); - } else if (periodLength === "m") { - return new Date(endDate.setMonth(endDate.getMonth() - +periodNumber)); - } else { - // periodLength is 'y' - return new Date( - endDate.setFullYear(endDate.getFullYear() - +periodNumber) - ); - } - }; - - // const [endDate, setEndDate] = useState(() => { - // return getEndDate(new Date(), `${MAX_PERIOD_BY_MONTH}m`); - // }); - - // const { status, data, isFetching } = useQuery({ - // queryKey: ["activeDeviceMetrics", snapId, period, type], - // queryFn: async () => { - // const response = await fetch( - // `/${snapId}/metrics/active-devices?active-devices=${type}&start=${startDate.toISOString().split("T")[0]}&end=${endDate.toISOString().split("T")[0]}` - // ); - - // 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, - // }); - const { status, data, isFetching } = useQuery({ queryKey: ["activeDeviceMetrics", snapId, period, type], queryFn: async () => { - // const response = await fetch( - // `/${snapId}/metrics/active-devices?active-devices=${type}&start=${startDate.toISOString().split("T")[0]}&end=${endDate.toISOString().split("T")[0]}` - // ); - - // 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(); - return await fetchData(); }, retry: 0, refetchOnWindowFocus: false, }); - const [fetchedData, setFetchedData] = useState( - undefined - ); - - const [responses2, setResponses2] = useState([]); + const parsePeriod = (period: string) => { + const [_, periodLength, periodTime] = period.trim().split(/(\d+)/); + return { periodLength: +periodLength, periodTime }; + }; const fetchData = async () => { - const periodLength = 5; - const period = "y"; - const pagePeriodLengthInMonths = 12; + const { periodTime, periodLength } = parsePeriod(period); + const pagePeriodLengthInMonths = 3; let totalPage = 1; - // if ( - // period === "d" || - // (period === "m" && periodLength <= pagePeriodLength) - // ) { - // totalPage = 1; - // } else { - totalPage = - period === "y" - ? Math.floor((periodLength * 12) / pagePeriodLengthInMonths) - : Math.floor(periodLength / pagePeriodLengthInMonths); - // } + 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=${periodLength}${period}&page=${i}&page-length=${pagePeriodLengthInMonths}` + `/${snapId}/metrics/active-devices?active-devices=${type}&period=${period}&page=${i}&page-length=${pagePeriodLengthInMonths}` ) ); } const results = await Promise.all(responses); + return await formatData(results); - // for (let i = 1; i <= totalPage; i++) { - // fetch( - // `/${snapId}/metrics/active-devices?active-devices=${type}&period=${periodLength}${period}&page=${i}` - // ) - // .then((result) => { - // result.json().then((d) => { - // setResponses2((prev) => [...prev, d]); - // }); - // }) - // .catch((e) => console.error(e)); - // } - // } }; - // useEffect(() => { - // formatData2(responses2); - // }, [responses2]); - - const formatData2 = async (results: any[]) => { - const buckets = []; - const series = new Map(); - - let seriesThatAreAddedBefore = 0; - const sorted = results.sort((a, b) => { - console.log("---sort---"); - console.log(a.active_devices.buckets[0]); - console.log(b.active_devices.buckets[0]); - console.log("----"); - return a.active_devices.buckets[0].localeCompare( - b.active_devices.buckets[0] - ); - }); - for (const data of sorted) { - const activeDeviceBuckets = data.active_devices.buckets; - - // merge data - buckets.push(...data.active_devices.buckets); - // fill the arr with 0's if the batch doesnt have that previous series - for (const seriesKey of series.keys()) { - if ( - !data.active_devices.series.find( - (activeDeviceSeries: { name: string }) => - activeDeviceSeries.name === seriesKey - ) - ) { - series.set(seriesKey, [ - ...series.get(seriesKey), - ...new Array(activeDeviceBuckets.length).fill(0), - ]); - } - } - - 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]); + 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"); } - - seriesThatAreAddedBefore += activeDeviceBuckets.length; } - const resultArray = Array.from(series.entries()).map(([key, value]) => ({ - name: key, - values: value, - })); - - setFetchedData({ - activeDevices: { - buckets, - name: "", - series: resultArray, - }, - }); + const data = await response.json(); + return data; }; - const formatData = async (results: any[]) => { + const formatData = async (results: Response[]) => { const buckets = []; const series = new Map(); let seriesThatAreAddedBefore = 0; for (const result of results.reverse()) { - const data = await result.json(); + const data = await handleResponse(result); const activeDeviceBuckets = data.active_devices.buckets; - // merge data - buckets.push(...data.active_devices.buckets); - // fill the arr with 0's if the batch doesnt have that previous series + buckets.push(...activeDeviceBuckets); + // fill the array with 0's if the batch doesnt have that previous series for (const seriesKey of series.keys()) { - if ( - !data.active_devices.series.find( - (activeDeviceSeries: { name: string }) => - activeDeviceSeries.name === seriesKey - ) - ) { + 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), @@ -247,6 +107,7 @@ function useActiveDeviceMetrics({ } } + // 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) @@ -267,7 +128,6 @@ function useActiveDeviceMetrics({ return { activeDevices: { buckets, - name: "", series: resultArray, }, }; 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 2dcb511f9f..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,9 +18,7 @@ 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"; @@ -31,11 +30,6 @@ function ActiveDeviceMetrics({ period, type, }); - // const { status, data, isFetching } = useActiveDeviceMetrics({ - // snapId, - // period, - // type, - // }); useEffect(() => { if (data) { @@ -49,23 +43,6 @@ function ActiveDeviceMetrics({ } }, [data]); - useEffect(() => { - console.log(status); - }, [status]); - const fetchData = async () => { - const data = await fetch(`/${snapId}/metrics/active-latest-devices`); - const d = await data.json(); - const activeDevices = d.latest_active_devices; - activeDevices && - setLatestActiveDevices( - String(activeDevices).replace(/(.)(?=(\d{3})+$)/g, "$1,") - ); - }; - - useEffect(() => { - void fetchData(); - }, []); - const onChange = (key: string, value: string) => { // clear the chart const svg = select(`${selector} svg`); diff --git a/webapp/metrics/helper.py b/webapp/metrics/helper.py index 9c93b5e029..0da7b65aff 100644 --- a/webapp/metrics/helper.py +++ b/webapp/metrics/helper.py @@ -22,6 +22,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,46 +49,23 @@ 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=start, - end=end, + start=dates['start'], + end=dates['end'], ), ] } -def build_metric_query_installed_base_new( +def build_active_device_metric_query( snap_id, installed_base, end, start ): - - # 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 { "filters": [ get_filter( diff --git a/webapp/publisher/snaps/metrics_views.py b/webapp/publisher/snaps/metrics_views.py index 2a12f3fcd2..22e5972897 100644 --- a/webapp/publisher/snaps/metrics_views.py +++ b/webapp/publisher/snaps/metrics_views.py @@ -176,17 +176,9 @@ def get_active_devices(snap_name): 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): - end = metrics_helper.get_last_metrics_processed_date() - - if metric_requested_bucket == "d": - start = end + relativedelta.relativedelta(days=-metric_requested_length) - elif metric_requested_bucket == "m": - start = end + relativedelta.relativedelta(months=-metric_requested_length) - elif metric_requested_bucket == "y": - # Go back an extra day to ensure the granularity increases - start = end + relativedelta.relativedelta( - years=-metric_requested_length, days=-1 - ) + dates = metrics_helper.get_dates_for_metric(metric_requested_length, metric_requested_bucket) + start = dates['start'] + end = dates['end'] else: if metric_requested_bucket == 'y': total_page_num = math.floor((metric_requested_length * 12) / page_time_length) @@ -200,42 +192,28 @@ def get_active_devices(snap_name): installed_base = logic.get_installed_based_metric(installed_base_metric) - new_metrics_query = metrics_helper.build_metric_query_installed_base_new( + new_metrics_query = metrics_helper.build_active_device_metric_query( snap_id=snap_id, installed_base=installed_base, end=end, start=start ) - start_time = time.time() metrics_response = publisher_api.get_publisher_metrics( flask.session, json=new_metrics_query ) - print("--- %s seconds ---" % (time.time() - start_time)) active_metrics = metrics_helper.find_metric( metrics_response["metrics"], installed_base ) - # Extract buckets and series + metrics_data = active_metrics buckets = metrics_data['buckets'] series = metrics_data['series'] metric_name = metrics_data['metric_name'] - print("bucket size: ", len(buckets), ", series: ", len(series)) - - # Target size for downsampling - # not sure about the numbers - if len(series) > 100: - start_time = time.time() + if len(series) > 500: downsampled_buckets, downsampled_series = downsample_series(buckets, series, 15) - print("--- %s seconds ---" % (time.time() - start_time)) - print(len(downsampled_buckets), len(downsampled_series), 15) - elif len(series) > 50: - start_time = time.time() - downsampled_buckets, downsampled_series = downsample_series(buckets, series, 30) - print("--- %s seconds ---" % (time.time() - start_time)) - print(len(downsampled_buckets), len(downsampled_series), 30) else: downsampled_buckets = buckets downsampled_series = series From ce12a2e3dee10bdb607480d5d9eeba427fa4085b Mon Sep 17 00:00:00 2001 From: Ilayda Cavusoglu Pars Date: Tue, 1 Oct 2024 21:42:51 +0300 Subject: [PATCH 10/10] feat: add jest tests --- jest.config.js | 3 + .../__tests__/useActiveDeviceMetrics.test.ts | 17 -- .../__tests__/useActiveDeviceMetrics.test.tsx | 167 ++++++++++++++++++ .../__tests__/ActiveDeviceMetrics.test.tsx | 27 ++- 4 files changed, 192 insertions(+), 22 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 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/pages/Metrics/__tests__/ActiveDeviceMetrics.test.tsx b/static/js/publisher-pages/pages/Metrics/__tests__/ActiveDeviceMetrics.test.tsx index c000b7984e..27b6d5357d 100644 --- a/static/js/publisher-pages/pages/Metrics/__tests__/ActiveDeviceMetrics.test.tsx +++ b/static/js/publisher-pages/pages/Metrics/__tests__/ActiveDeviceMetrics.test.tsx @@ -89,20 +89,22 @@ describe("ActiveDeviceMetrics", () => { test("renders the information correctly", async () => { // @ts-ignore useQuery.mockImplementation((params) => { + console.log(params); if (params) { if (params.queryKey[0] === "activeDeviceMetrics") { return { 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 +133,11 @@ describe("ActiveDeviceMetrics", () => { status: "error", data: undefined, }; + } else if (params.queryKey[0] === "latestActiveDevicesMetric") { + return { + status: "success", + data: 5, + }; } else { return { status: "success", @@ -166,6 +173,11 @@ describe("ActiveDeviceMetrics", () => { isFetching: true, data: undefined, }; + } else if (params.queryKey[0] === "latestActiveDevicesMetric") { + return { + status: "success", + data: 5, + }; } else { return { status: "success", @@ -199,6 +211,11 @@ describe("ActiveDeviceMetrics", () => { status: "success", data: undefined, }; + } else if (params.queryKey[0] === "latestActiveDevicesMetric") { + return { + status: "success", + data: 0, + }; } else { return { status: "success",