From b7148d0426ca2ea68e448ff0aacfd2424b2049ae Mon Sep 17 00:00:00 2001 From: Steve Rydz Date: Mon, 9 Sep 2024 12:22:11 +0100 Subject: [PATCH 01/24] 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 ---------- 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 +---- 22 files changed, 773 insertions(+), 358 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 diff --git a/static/js/global.d.ts b/static/js/global.d.ts index 0a494042c2..3143111a0d 100644 --- a/static/js/global.d.ts +++ b/static/js/global.d.ts @@ -16,4 +16,10 @@ declare interface Window { // eslint-disable-next-line @typescript-eslint/no-explicit-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 913491ee50..6ef3ee7242 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: (value: boolean) => void; }): JSX.Element { + const location = useLocation(); const { data: publisherData } = usePublisher(); return ( @@ -46,7 +48,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 f741b1fe5d..1e83e32045 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/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 8cacd7e789b00ea78e1b60b06ba9c21a9f9b1cdd Mon Sep 17 00:00:00 2001 From: Steve Rydz Date: Tue, 10 Sep 2024 11:14:56 +0100 Subject: [PATCH 02/24] 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 | 2 +- .../utils/index.ts | 0 .../settings/components/App/index.ts | 1 - static/js/publisher/settings/index.tsx | 14 -- 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 - 32 files changed, 763 insertions(+), 87 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 (94%) rename static/js/{publisher/settings => publisher-pages}/utils/index.ts (100%) 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/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 3143111a0d..251ac12813 100644 --- a/static/js/global.d.ts +++ b/static/js/global.d.ts @@ -16,10 +16,32 @@ declare interface Window { // eslint-disable-next-line @typescript-eslint/no-explicit-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 1e83e32045..c8b1990899 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 43955597d8..06c5762d2e 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 94% rename from static/js/publisher/settings/utils/getSettingsData.ts rename to static/js/publisher-pages/utils/getSettingsData.ts index 3bbe7ed1cc..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) { 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/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/index.d.ts b/static/js/publisher/settings/types/index.d.ts deleted file mode 100644 index f62ece5ae4..0000000000 --- a/static/js/publisher/settings/types/index.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Empty export to mark this file as a module. -// This is required to augment global scope. -export {}; - -declare global { - interface Window { - SENTRY_DSN: string; - CSRF_TOKEN: string; - settingsData: any; - countries: Array<{ key: string; name: string }>; - } -} diff --git a/templates/publisher/settings.html b/templates/publisher/settings.html deleted file mode 100644 index edeff1d917..0000000000 --- a/templates/publisher/settings.html +++ /dev/null @@ -1,31 +0,0 @@ -{% extends "publisher/_publisher_layout.html" %} - -{% block meta_title %} -Settings for {% if display_title %}{{ display_title }}{% else %}{{ snap_title }}{% endif %} -{% endblock %} - -{% block content %} -
- - -{% endblock %} - - diff --git a/templates/store/publisher.html b/templates/store/publisher.html index 68c4fd4e28..95b7c4c29a 100644 --- a/templates/store/publisher.html +++ b/templates/store/publisher.html @@ -4,12 +4,30 @@
{% endblock %} diff --git a/tests/publisher/snaps/tests_post_settings.py b/tests/publisher/snaps/tests_post_settings.py index a81cf379e6..3725ee1aa4 100644 --- a/tests/publisher/snaps/tests_post_settings.py +++ b/tests/publisher/snaps/tests_post_settings.py @@ -153,7 +153,7 @@ def test_return_error_update_one_field(self): ) self.assertEqual(response.status_code, 200) - self.assert_template_used("publisher/settings.html") + self.assert_template_used("store/publisher.html") self.assert_context("snap_id", self.snap_id) self.assert_context("snap_title", "test snap") @@ -232,7 +232,7 @@ def test_return_error_udpate_all_field(self): ) self.assertEqual(response.status_code, 200) - self.assert_template_used("publisher/settings.html") + self.assert_template_used("store/publisher.html") # Not updatable fields self.assert_context("snap_id", self.snap_id) @@ -321,6 +321,6 @@ def test_return_error_invalid_field(self): ) self.assertEqual(response.status_code, 200) - self.assert_template_used("publisher/settings.html") + self.assert_template_used("store/publisher.html") self.assert_context("field_errors", {"description": "error message"}) diff --git a/tests/publisher/snaps/tests_settings.py b/tests/publisher/snaps/tests_settings.py index ddac96441b..7149e4826f 100644 --- a/tests/publisher/snaps/tests_settings.py +++ b/tests/publisher/snaps/tests_settings.py @@ -88,7 +88,7 @@ def test_account_logged_in(self): self.check_call_by_api_url(responses.calls) assert response.status_code == 200 - self.assert_template_used("publisher/settings.html") + self.assert_template_used("store/publisher.html") self.assert_context("snap_id", "id") self.assert_context("snap_title", "test snap") diff --git a/webapp/publisher/snaps/settings_views.py b/webapp/publisher/snaps/settings_views.py index 7b1bba629a..59cd8d999c 100644 --- a/webapp/publisher/snaps/settings_views.py +++ b/webapp/publisher/snaps/settings_views.py @@ -77,7 +77,7 @@ def get_settings(snap_name, return_json=False): if return_json: return flask.jsonify(context) - return flask.render_template("publisher/settings.html", **context) + return flask.render_template("store/publisher.html", **context) @login_required @@ -187,6 +187,6 @@ def post_settings(snap_name, return_json=False): if return_json: return flask.jsonify(context) - return flask.render_template("publisher/settings.html", **context) + return flask.render_template("store/publisher.html", **context) return flask.redirect(flask.url_for(".get_settings", snap_name=snap_name)) diff --git a/webpack.config.entry.js b/webpack.config.entry.js index d2a0e3722c..50179b267f 100644 --- a/webpack.config.entry.js +++ b/webpack.config.entry.js @@ -18,7 +18,6 @@ module.exports = { "publisher-details": "./static/js/public/publisher-details.ts", "brand-store": "./static/js/brand-store/brand-store.tsx", "publisher-listing": "./static/js/publisher/listing/index.tsx", - "publisher-settings": "./static/js/publisher/settings/index.tsx", "about-listing": "./static/js/public/about/listing.ts", store: "./static/js/store/index.tsx", "publisher-pages": "./static/js/publisher-pages/index.tsx", From fbb281a484dccfcb29c58283e74d95a5d721f84c Mon Sep 17 00:00:00 2001 From: ilayda-cp Date: Fri, 20 Sep 2024 12:59:35 +0300 Subject: [PATCH 03/24] 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 | 41 +- 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, 1377 insertions(+), 615 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 c8b1990899..a38925af53 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 0d7702b84b..bb122a7bfb 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 63fbc4b894..ed8b559aa9 100644 --- a/static/js/publisher/metrics/metrics.ts +++ b/static/js/publisher/metrics/metrics.ts @@ -7,33 +7,13 @@ type Series = { values: Array; }; -type Metrics = { - activeDevices: { - annotations: { - buckets: Array; - name: string; - series: Array; - }; - metrics: { - buckets: Array; - series: Array; - }; - selector: string; - type: string; - }; - defaultTrack: string; - territories: { - metrics: { - [key: string]: { - code: string; - color_rgb: string; - name: string; - number_of_users: number; - percentage_of_users: number; - }; - }; - selector: string; +type ActiveDeviceMetric = { + metrics: { + buckets: Array; + series: Array; }; + selector: string; + type: string; }; function renderMetrics(metrics: Metrics) { @@ -42,7 +22,7 @@ function renderMetrics(metrics: Metrics) { buckets: Array; } = { series: [], - buckets: metrics.activeDevices.metrics.buckets, + buckets: metrics.metrics.buckets, }; metrics.activeDevices.metrics.series.forEach((series) => { @@ -96,9 +76,10 @@ function renderMetrics(metrics: Metrics) { } }); } +} - // Territories - territoriesMetrics(metrics.territories.selector, metrics.territories.metrics); +function renderTerritoriesMetrics(metrics: TerritoriesMetric) { + territoriesMetrics(metrics.selector, metrics.metrics); } /** @@ -266,4 +247,4 @@ function renderPublisherMetrics(options: { getChunk(chunkedSnaps); } -export { renderMetrics, renderPublisherMetrics }; +export { 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 5cc302865e..6732c34138 100644 --- a/webapp/metrics/helper.py +++ b/webapp/metrics/helper.py @@ -30,7 +30,7 @@ def get_last_metrics_processed_date(): return last_metrics_processed.date() - days_to_skip -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 @@ -40,8 +40,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() @@ -63,6 +62,25 @@ def build_metrics_json( start=start, end=end, ), + ] + } + + +def build_metric_query_country(snap_id): + """Build the json that will be requested to the API + + :param snap_id The snap id + :param installed_base_metric The base metric requested + :param metric_period The metric period requested, by default 30 + :param metric_bucket The metric bucket, by default 'd' + + :returns A dictionary with the filters for the metrics API, by default + returns also the 'weekly_installed_base_by_country'. + """ + end = get_last_metrics_processed_date() + + return { + "filters": [ get_filter( metric_name="weekly_installed_base_by_country", snap_id=snap_id, diff --git a/webapp/publisher/snaps/metrics_views.py b/webapp/publisher/snaps/metrics_views.py index 77eb56ca74..29ec00021b 100644 --- a/webapp/publisher/snaps/metrics_views.py +++ b/webapp/publisher/snaps/metrics_views.py @@ -5,7 +5,10 @@ import flask import webapp.metrics.helper as metrics_helper import webapp.metrics.metrics as metrics -from canonicalwebteam.store_api.stores.snapstore import SnapPublisher +from canonicalwebteam.store_api.stores.snapstore import ( + SnapPublisher, + SnapStore, +) # Local from webapp.helpers import api_publisher_session @@ -13,6 +16,7 @@ from webapp.publisher.snaps import logic publisher_api = SnapPublisher(api_publisher_session) +store_api = SnapStore(api_publisher_session) @login_required @@ -51,14 +55,23 @@ def get_measure_snap(snap_name): def publisher_snap_metrics(snap_name): """ A view to display the snap metrics page for specific snaps. - - This queries the snapcraft API (api.snapcraft.io) and passes - some of the data through to the publisher/metrics.html template, - with appropriate sanitation. """ - details = publisher_api.get_snap_info(snap_name, flask.session) - default_track = details.get("default_track", "latest") + context = { + # Data direct from details API + "snap_name": snap_name, + # pass snap id from here? + "is_linux": "Linux" in flask.request.headers["User-Agent"], + } + + return flask.render_template("publisher/metrics.html", **context) + +@login_required +def get_active_devices(snap_name): + snap_details = store_api.get_item_details( + snap_name, api_version=2, fields=["snap-id"] + ) + snap_id = snap_details["snap-id"] metric_requested = logic.extract_metrics_period( flask.request.args.get("period", default="30d", type=str) ) @@ -68,8 +81,8 @@ def publisher_snap_metrics(snap_name): ) installed_base = logic.get_installed_based_metric(installed_base_metric) - metrics_query_json = metrics_helper.build_metrics_json( - snap_id=details["snap_id"], + metrics_query_json = metrics_helper.build_metric_query_installed_base( + snap_id=snap_id, installed_base=installed_base, metric_period=metric_requested["int"], metric_bucket=metric_requested["bucket"], @@ -79,24 +92,11 @@ def publisher_snap_metrics(snap_name): flask.session, json=metrics_query_json ) - latest_day_period = logic.extract_metrics_period("1d") - latest_installed_base = logic.get_installed_based_metric("version") - latest_day_query_json = metrics_helper.build_metrics_json( - snap_id=details["snap_id"], - installed_base=latest_installed_base, - metric_period=latest_day_period["int"], - metric_bucket=latest_day_period["bucket"], - ) - latest_day_response = publisher_api.get_publisher_metrics( - flask.session, json=latest_day_query_json - ) - active_metrics = metrics_helper.find_metric( metrics_response["metrics"], installed_base ) series = active_metrics["series"] - if active_metrics["metric_name"] == "weekly_installed_base_by_channel": for s in series: if "/" not in s["name"]: @@ -115,8 +115,19 @@ def publisher_snap_metrics(snap_name): status=active_metrics["status"], ) + # get latest active devices + latest_day_period = logic.extract_metrics_period("1d") + latest_installed_base = logic.get_installed_based_metric("version") + latest_day_query_json = metrics_helper.build_metric_query_installed_base( + snap_id=snap_id, + installed_base=latest_installed_base, + metric_period=latest_day_period["int"], + metric_bucket=latest_day_period["bucket"], + ) + latest_day_response = publisher_api.get_publisher_metrics( + flask.session, json=latest_day_query_json + ) latest_active = 0 - if active_devices: latest_active = active_devices.get_number_latest_active_devices() @@ -135,23 +146,17 @@ def publisher_snap_metrics(snap_name): latest_active_devices.get_number_latest_active_devices() ) - country_metric = metrics_helper.find_metric( - metrics_response["metrics"], "weekly_installed_base_by_country" - ) - country_devices = metrics.CountryDevices( - name=country_metric["metric_name"], - series=country_metric["series"], - buckets=country_metric["buckets"], - status=country_metric["status"], - private=True, + return flask.jsonify( + { + "active_devices": dict(active_devices), + "latest_active_devices": latest_active, + } ) - territories_total = 0 - if country_devices: - territories_total = country_devices.get_number_territories() - - nodata = not any([country_devices, active_devices]) +@login_required +def get_metric_annotaion(snap_name): + details = publisher_api.get_snap_info(snap_name, flask.session) annotations = {"name": "annotations", "series": [], "buckets": []} for category in details["categories"]["items"]: @@ -178,25 +183,41 @@ def publisher_snap_metrics(snap_name): annotations["series"] = sorted( annotations["series"], key=lambda k: k["date"] ) + return flask.jsonify(annotations) - context = { - # Data direct from details API - "snap_name": snap_name, - "snap_title": details["title"], - "publisher_name": details["publisher"]["display-name"], - "metric_period": metric_requested["period"], - "active_device_metric": installed_base_metric, - "default_track": default_track, - "private": details["private"], - # Metrics data - "nodata": nodata, - "latest_active_devices": latest_active, - "active_devices": dict(active_devices), - "territories_total": territories_total, - "territories": country_devices.country_data, - "active_devices_annotations": annotations, - # Context info - "is_linux": "Linux" in flask.request.headers["User-Agent"], - } - return flask.render_template("publisher/metrics.html", **context) +@login_required +def get_country_metric(snap_name): + snap_details = store_api.get_item_details( + snap_name, api_version=2, fields=["snap-id"] + ) + snap_id = snap_details["snap-id"] + metrics_query_json = metrics_helper.build_metric_query_country( + snap_id=snap_id, + ) + + metrics_response = publisher_api.get_publisher_metrics( + flask.session, json=metrics_query_json + ) + + country_metric = metrics_helper.find_metric( + metrics_response["metrics"], "weekly_installed_base_by_country" + ) + country_devices = metrics.CountryDevices( + name=country_metric["metric_name"], + series=country_metric["series"], + buckets=country_metric["buckets"], + status=country_metric["status"], + private=True, + ) + + territories_total = 0 + if country_devices: + territories_total = country_devices.get_number_territories() + + return flask.jsonify( + { + "active_devices": country_devices.country_data, + "territories_total": territories_total, + } + ) diff --git a/webapp/publisher/snaps/views.py b/webapp/publisher/snaps/views.py index 07d57c1a0e..82b6bc1121 100644 --- a/webapp/publisher/snaps/views.py +++ b/webapp/publisher/snaps/views.py @@ -211,6 +211,21 @@ view_func=metrics_views.publisher_snap_metrics, ) +publisher_snaps.add_url_rule( + "//metrics/active-devices", + view_func=metrics_views.get_active_devices, +) + +publisher_snaps.add_url_rule( + "//metrics/active-device-annotation", + view_func=metrics_views.get_metric_annotaion, +) + +publisher_snaps.add_url_rule( + "//metrics/country-metric", + view_func=metrics_views.get_country_metric, +) + # Publice views publisher_snaps.add_url_rule( "//publicise", diff --git a/webpack.config.entry.js b/webpack.config.entry.js index 50179b267f..0bdcf04789 100644 --- a/webpack.config.entry.js +++ b/webpack.config.entry.js @@ -21,4 +21,5 @@ module.exports = { "about-listing": "./static/js/public/about/listing.ts", store: "./static/js/store/index.tsx", "publisher-pages": "./static/js/publisher-pages/index.tsx", + metrics: "./static/js/publisher-pages/index.tsx", }; From a69af94d4933c4764418173ca0b38185a241959a Mon Sep 17 00:00:00 2001 From: ilayda-cp Date: Thu, 3 Oct 2024 13:30:39 +0300 Subject: [PATCH 04/24] Wd 15262 improve performance of metric page (#4863) * feat: added downsampling and pagination --- jest.config.js | 3 + .../__tests__/useActiveDeviceMetrics.test.ts | 17 -- .../__tests__/useActiveDeviceMetrics.test.tsx | 167 ++++++++++++++++++ .../hooks/useActiveDeviceMetrics.ts | 128 ++++++++++++-- .../hooks/useLatestActiveDevicesMetric.ts | 26 +++ .../hooks/useMetricsAnnotation.ts | 2 + .../pages/Metrics/ActiveDeviceMetrics.tsx | 18 +- .../__tests__/ActiveDeviceMetrics.test.tsx | 26 ++- webapp/metrics/helper.py | 141 +++++++++++++-- webapp/publisher/snaps/metrics_views.py | 117 +++++++++--- webapp/publisher/snaps/views.py | 5 + 11 files changed, 566 insertions(+), 84 deletions(-) delete mode 100644 static/js/publisher-pages/hooks/__tests__/useActiveDeviceMetrics.test.ts create mode 100644 static/js/publisher-pages/hooks/__tests__/useActiveDeviceMetrics.test.tsx create mode 100644 static/js/publisher-pages/hooks/useLatestActiveDevicesMetric.ts diff --git a/jest.config.js b/jest.config.js index 01a13a0121..bfd1c4eeef 100644 --- a/jest.config.js +++ b/jest.config.js @@ -12,4 +12,7 @@ module.exports = { moduleNameMapper: { "\\.(scss|sass|css)$": "identity-obj-proxy", }, + globals: { + fetch: global.fetch, + }, }; diff --git a/static/js/publisher-pages/hooks/__tests__/useActiveDeviceMetrics.test.ts b/static/js/publisher-pages/hooks/__tests__/useActiveDeviceMetrics.test.ts deleted file mode 100644 index 0f4a6587ae..0000000000 --- a/static/js/publisher-pages/hooks/__tests__/useActiveDeviceMetrics.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as ReactQuery from "react-query"; -import { renderHook } from "@testing-library/react"; -import useActiveDeviceMetrics from "../useActiveDeviceMetrics"; - -describe("useActiveDeviceMetrics", () => { - test("Calls useQuery", () => { - jest.spyOn(ReactQuery, "useQuery").mockImplementation(jest.fn()); - renderHook(() => - useActiveDeviceMetrics({ - period: "30d", - snapId: "test-id", - type: "version", - }) - ); - expect(ReactQuery.useQuery).toHaveBeenCalled(); - }); -}); diff --git a/static/js/publisher-pages/hooks/__tests__/useActiveDeviceMetrics.test.tsx b/static/js/publisher-pages/hooks/__tests__/useActiveDeviceMetrics.test.tsx new file mode 100644 index 0000000000..2285f4ecad --- /dev/null +++ b/static/js/publisher-pages/hooks/__tests__/useActiveDeviceMetrics.test.tsx @@ -0,0 +1,167 @@ +import * as ReactQuery from "react-query"; +import { QueryClient, QueryClientProvider } from "react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import useActiveDeviceMetrics from "../useActiveDeviceMetrics"; + +describe("useActiveDeviceMetrics", () => { + test("Calls useQuery", () => { + const spy = jest.spyOn(ReactQuery, "useQuery").mockReturnValue({ + data: [], + status: "success", + isFetcing: false, + } as any); + + renderHook(() => + useActiveDeviceMetrics({ + period: "30d", + snapId: "test-id", + type: "version", + }) + ); + expect(ReactQuery.useQuery).toHaveBeenCalled(); + spy.mockRestore(); + }); + + const createWrapper = () => { + const queryClient = new QueryClient(); + return ({ children }: any) => ( + {children} + ); + }; + + test("if the page size is set to less than 3 months, do not paginate ", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + active_devices: { + buckets: [ + "2024-09-26", + "2024-09-27", + "2024-09-28", + "2024-09-29", + "2024-09-30", + ], + name: "weekly_installed_base_by_version", + series: [ + { + name: "1.0", + values: [5, 5, 0, 4, 4], + }, + ], + }, + latest_active_devices: 4, + total_page_num: 1, + }), + ok: true, + }) + ) as jest.Mock; + + const { result } = renderHook( + () => + useActiveDeviceMetrics({ + period: "30d", + snapId: "test-id", + type: "version", + }), + { + wrapper: createWrapper(), + } + ); + + await waitFor(() => expect(result.current.status).toBe("success")); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(result.current.data).toMatchObject({ + activeDevices: { + buckets: [ + "2024-09-26", + "2024-09-27", + "2024-09-28", + "2024-09-29", + "2024-09-30", + ], + series: [{ name: "1.0", values: [5, 5, 0, 4, 4] }], + }, + }); + (global.fetch as jest.Mock).mockRestore(); + }); + + test("if the page size is greater than 3 months, request data over multiple requests", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + active_devices: { + buckets: [ + "2024-09-26", + "2024-09-27", + "2024-09-28", + "2024-09-29", + "2024-09-30", + ], + name: "weekly_installed_base_by_version", + series: [ + { + name: "1.0", + values: [5, 5, 0, 4, 4], + }, + ], + }, + latest_active_devices: 4, + total_page_num: 1, + }), + ok: true, + }) + ) as jest.Mock; + + const { result } = renderHook( + () => + useActiveDeviceMetrics({ + period: "2y", + snapId: "test-id", + type: "version", + }), + { + wrapper: createWrapper(), + } + ); + + await waitFor(() => expect(result.current.status).toBe("success")); + + expect(global.fetch).toHaveBeenCalledTimes(8); + (global.fetch as jest.Mock).mockRestore(); + }); + + test("if the request 404, empty data should be returned", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + json: () => Promise.resolve(undefined), + ok: false, + status: 404, + }) + ) as jest.Mock; + + const { result } = renderHook( + () => + useActiveDeviceMetrics({ + period: "30d", + snapId: "test-id", + type: "version", + }), + { + wrapper: createWrapper(), + } + ); + await waitFor(() => expect(result.current.status).toBe("success")); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(result.current.data).toMatchObject({ + activeDevices: { + buckets: [], + series: [], + }, + }); + (global.fetch as jest.Mock).mockRestore(); + }); +}); diff --git a/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts b/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts index 3bac68434e..eea8ceba41 100644 --- a/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts +++ b/static/js/publisher-pages/hooks/useActiveDeviceMetrics.ts @@ -9,32 +9,122 @@ function useActiveDeviceMetrics({ period: string; type: string; }) { - return useQuery({ + const { status, data, isFetching } = useQuery({ queryKey: ["activeDeviceMetrics", snapId, period, type], queryFn: async () => { - const response = await fetch( - `/${snapId}/metrics/active-devices?period=${period}&active-devices=${type}` + return await fetchData(); + }, + retry: 0, + refetchOnWindowFocus: false, + }); + + const parsePeriod = (period: string) => { + const [_, periodLength, periodTime] = period.trim().split(/(\d+)/); + return { periodLength: +periodLength, periodTime }; + }; + + const fetchData = async () => { + const { periodTime, periodLength } = parsePeriod(period); + const pagePeriodLengthInMonths = 3; + + let totalPage = 1; + if ( + periodTime === "d" || + (periodTime === "m" && periodLength <= pagePeriodLengthInMonths) + ) { + totalPage = 1; + } else { + totalPage = + periodTime === "y" + ? Math.floor((periodLength * 12) / pagePeriodLengthInMonths) + : Math.floor(periodLength / pagePeriodLengthInMonths); + } + + const responses = []; + for (let i = 1; i <= totalPage; i++) { + responses.push( + fetch( + `/${snapId}/metrics/active-devices?active-devices=${type}&period=${period}&page=${i}&page-length=${pagePeriodLengthInMonths}` + ) ); + } + + const results = await Promise.all(responses); + + return await formatData(results); + }; + + const handleResponse = async (response: Response) => { + if (!response.ok) { + if (response.status === 404) { + return { + active_devices: { + buckets: [], + series: [], + }, + }; + } else { + throw new Error("Unable to fetch latest active device information"); + } + } + + const data = await response.json(); + return data; + }; + + const formatData = async (results: Response[]) => { + const buckets = []; + const series = new Map(); - if (!response.ok) { - if (response.status === 404) { - return { - latest_active_devices: 0, - active_devices: { - series: [], - buckets: [], - }, - }; - } else { - throw new Error("Unable to fetch active device metrics"); + let seriesThatAreAddedBefore = 0; + + for (const result of results.reverse()) { + const data = await handleResponse(result); + + const activeDeviceBuckets = data.active_devices.buckets; + + buckets.push(...activeDeviceBuckets); + // fill the array with 0's if the batch doesnt have that previous series + for (const seriesKey of series.keys()) { + const seriesExistInBatch = data.active_devices.series.find( + (activeDeviceSeries: { name: string }) => + activeDeviceSeries.name === seriesKey + ); + if (!seriesExistInBatch) { + series.set(seriesKey, [ + ...series.get(seriesKey), + ...new Array(activeDeviceBuckets.length).fill(0), + ]); } } - return await response.json(); - }, - retry: 0, - refetchOnWindowFocus: false, - }); + // fill the array with 0's if new series introduced in the batch + for (const activeDeviceSeries of data.active_devices.series) { + const key = activeDeviceSeries.name; + const prevData = series.has(key) + ? series.get(key) + : new Array(seriesThatAreAddedBefore).fill(0); + + series.set(key, [...prevData, ...activeDeviceSeries.values]); + } + + seriesThatAreAddedBefore += activeDeviceBuckets.length; + } + + const resultArray = Array.from(series.entries()).map(([key, value]) => ({ + name: key, + values: value, + })); + + return { + activeDevices: { + buckets, + series: resultArray, + }, + }; + }; + + return { status, data, isFetching }; } export default useActiveDeviceMetrics; diff --git a/static/js/publisher-pages/hooks/useLatestActiveDevicesMetric.ts b/static/js/publisher-pages/hooks/useLatestActiveDevicesMetric.ts new file mode 100644 index 0000000000..d97d6dddd1 --- /dev/null +++ b/static/js/publisher-pages/hooks/useLatestActiveDevicesMetric.ts @@ -0,0 +1,26 @@ +import { useQuery } from "react-query"; + +function useLatestActiveDevicesMetric(snapId?: string) { + return useQuery({ + queryKey: ["latestActiveDevicesMetric", snapId], + queryFn: async () => { + const response = await fetch(`/${snapId}/metrics/active-latest-devices`); + + if (!response.ok) { + if (response.status === 404) { + return null; + } else { + throw new Error("Unable to fetch latest active device information"); + } + } + + const data = await response.json(); + const activeDevices = data.latest_active_devices; + return String(activeDevices).replace(/(.)(?=(\d{3})+$)/g, "$1,"); + }, + retry: 0, + refetchOnWindowFocus: false, + }); +} + +export default useLatestActiveDevicesMetric; diff --git a/static/js/publisher-pages/hooks/useMetricsAnnotation.ts b/static/js/publisher-pages/hooks/useMetricsAnnotation.ts index 3e82ff575d..d28850e836 100644 --- a/static/js/publisher-pages/hooks/useMetricsAnnotation.ts +++ b/static/js/publisher-pages/hooks/useMetricsAnnotation.ts @@ -16,6 +16,8 @@ function useMetricsAnnotation(snapId?: string) { return data; }, + retry: 0, + refetchOnWindowFocus: false, }); } diff --git a/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx b/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx index 9067f18c13..13a17d7cc0 100644 --- a/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx +++ b/static/js/publisher-pages/pages/Metrics/ActiveDeviceMetrics.tsx @@ -7,6 +7,7 @@ import { select } from "d3-selection"; import ActiveDeviceAnnotation from "./ActiveDeviceAnnotation"; import { ActiveDeviceMetricFilter } from "./ActiveDeviceMetricFilter"; import useActiveDeviceMetrics from "../../hooks/useActiveDeviceMetrics"; +import useLatestActiveDevicesMetric from "../../hooks/useLatestActiveDevicesMetric"; function ActiveDeviceMetrics({ isEmpty, @@ -17,12 +18,11 @@ function ActiveDeviceMetrics({ }): JSX.Element { const { snapId } = useParams(); const [searchParams, setSearchParams] = useSearchParams(); - const [latestActiveDevices, setLatestActiveDevices] = useState( - null - ); + const { data: latestActiveDevices } = useLatestActiveDevicesMetric(snapId); const period = searchParams.get("period") ?? "30d"; const type = searchParams.get("active-devices") ?? "version"; + const selector = "#activeDevices"; const { status, data, isFetching } = useActiveDeviceMetrics({ @@ -33,19 +33,13 @@ function ActiveDeviceMetrics({ useEffect(() => { if (data) { - const activeDevices = data.latest_active_devices; - activeDevices && - setLatestActiveDevices( - String(activeDevices).replace(/(.)(?=(\d{3})+$)/g, "$1,") - ); - - data.active_devices && + data.activeDevices && renderActiveDevicesMetrics({ selector, - metrics: data.active_devices, + metrics: data.activeDevices, type, }); - onDataLoad(data.active_devices?.buckets?.length); + onDataLoad(data.activeDevices?.buckets?.length); } }, [data]); diff --git a/static/js/publisher-pages/pages/Metrics/__tests__/ActiveDeviceMetrics.test.tsx b/static/js/publisher-pages/pages/Metrics/__tests__/ActiveDeviceMetrics.test.tsx index c000b7984e..1543a8c562 100644 --- a/static/js/publisher-pages/pages/Metrics/__tests__/ActiveDeviceMetrics.test.tsx +++ b/static/js/publisher-pages/pages/Metrics/__tests__/ActiveDeviceMetrics.test.tsx @@ -95,14 +95,15 @@ describe("ActiveDeviceMetrics", () => { status: "success", data: mockActiveDeviceMetrics, }; + } else if (params.queryKey[0] === "latestActiveDevicesMetric") { + return { + status: "success", + data: 5, + }; } else { return { status: "success", - data: { - buckets: [], - name: "annotations", - series: [], - }, + data: undefined, }; } } @@ -131,6 +132,11 @@ describe("ActiveDeviceMetrics", () => { status: "error", data: undefined, }; + } else if (params.queryKey[0] === "latestActiveDevicesMetric") { + return { + status: "success", + data: 5, + }; } else { return { status: "success", @@ -166,6 +172,11 @@ describe("ActiveDeviceMetrics", () => { isFetching: true, data: undefined, }; + } else if (params.queryKey[0] === "latestActiveDevicesMetric") { + return { + status: "success", + data: 5, + }; } else { return { status: "success", @@ -199,6 +210,11 @@ describe("ActiveDeviceMetrics", () => { status: "success", data: undefined, }; + } else if (params.queryKey[0] === "latestActiveDevicesMetric") { + return { + status: "success", + data: 0, + }; } else { return { status: "success", diff --git a/webapp/metrics/helper.py b/webapp/metrics/helper.py index 6732c34138..bc65384b3c 100644 --- a/webapp/metrics/helper.py +++ b/webapp/metrics/helper.py @@ -1,5 +1,6 @@ import datetime from dateutil import relativedelta +import math def get_filter(metric_name, snap_id, start, end): @@ -30,6 +31,21 @@ def get_last_metrics_processed_date(): return last_metrics_processed.date() - days_to_skip +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" ): @@ -42,18 +58,21 @@ def build_metric_query_installed_base( :returns A dictionary with the filters for the metrics API. """ - end = get_last_metrics_processed_date() - if metric_bucket == "d": - start = end + relativedelta.relativedelta(days=-metric_period) - elif metric_bucket == "m": - start = end + relativedelta.relativedelta(months=-metric_period) - elif metric_bucket == "y": - # Go back an extra day to ensure the granularity increases - start = end + relativedelta.relativedelta( - years=-metric_period, days=-1 - ) + dates = get_dates_for_metric(metric_period, metric_bucket) + return { + "filters": [ + get_filter( + metric_name=installed_base, + snap_id=snap_id, + start=dates["start"], + end=dates["end"], + ), + ] + } + +def build_active_device_metric_query(snap_id, installed_base, end, start): return { "filters": [ get_filter( @@ -155,3 +174,105 @@ def transform_metrics(metrics, metrics_response, snaps): metrics["buckets"] = metric["buckets"] return metrics + + +def lttb_select_indices(values, target_size): + """ + Selects indices using the LTTB algorithm for downsampling, + treating None as 0. + """ + n = len(values) + if n <= target_size: + return list(range(n)) + + # Initialize bucket size + bucket_size = (n - 2) / (target_size - 2) + indices = [] + + current_bucket_start = 0 + for i in range(1, target_size - 1): + next_bucket_start = min(math.ceil((i + 1) * bucket_size), n - 1) + + max_area = 0 + max_area_idx = current_bucket_start + + point1 = ( + current_bucket_start, + ( + values[current_bucket_start] + if values[current_bucket_start] is not None + else 0 + ), + ) + point2 = ( + next_bucket_start, + ( + values[next_bucket_start] + if values[next_bucket_start] is not None + else 0 + ), + ) + + for j in range(current_bucket_start + 1, next_bucket_start): + val_j = values[j] if values[j] is not None else 0 + + # Area of triangle formed by point1, point2, and the current point + area = abs( + (point1[0] - point2[0]) * (val_j - point1[1]) + - (point1[0] - j) * (point2[1] - point1[1]) + ) + if area > max_area: + max_area = area + max_area_idx = j + + indices.append(max_area_idx) + current_bucket_start = next_bucket_start + + indices.append(n - 1) + return indices + + +def normalize_series(series, bucket_count): + """ + Ensure all value arrays in the series have the same size + by padding with 0s. + """ + for item in series: + values = item["values"] + # If the series has no values, fill it with 0s + if not values: + item["values"] = [0] * bucket_count + # Extend the values with 0 if they are shorter than the bucket count + elif len(values) < bucket_count: + item["values"].extend([0] * (bucket_count - len(values))) + + +def downsample_series(buckets, series, target_size): + """Downsample each series in the data, treating None as 0.""" + downsampled_buckets = [] + downsampled_series = [] + + # Handle case where series is empty + if not series: + return buckets[:target_size], [] + + bucket_count = len(buckets) + # Normalize series first to make sure all series have the same length + normalize_series(series, bucket_count) + + # Downsample each series independently + for item in series: + name = item["name"] + values = item["values"] + + selected_indices = lttb_select_indices(values, target_size) + + # Collect the buckets and values based on the selected indices + downsampled_buckets = [buckets[i] for i in selected_indices] + downsampled_values = [ + values[i] if values[i] is not None else 0 for i in selected_indices + ] + + downsampled_series.append({"name": name, "values": downsampled_values}) + + return downsampled_buckets, downsampled_series diff --git a/webapp/publisher/snaps/metrics_views.py b/webapp/publisher/snaps/metrics_views.py index 29ec00021b..0588846f6d 100644 --- a/webapp/publisher/snaps/metrics_views.py +++ b/webapp/publisher/snaps/metrics_views.py @@ -1,5 +1,7 @@ # Standard library from json import loads +from dateutil import relativedelta +import math # Packages import flask @@ -15,9 +17,13 @@ from webapp.decorators import login_required from webapp.publisher.snaps import logic + publisher_api = SnapPublisher(api_publisher_session) store_api = SnapStore(api_publisher_session) +downsample_data_limit = 500 +downsample_target_size = 10 + @login_required def get_account_snaps_metrics(): @@ -68,53 +74,124 @@ def publisher_snap_metrics(snap_name): @login_required def get_active_devices(snap_name): + snap_details = store_api.get_item_details( snap_name, api_version=2, fields=["snap-id"] ) + snap_id = snap_details["snap-id"] - metric_requested = logic.extract_metrics_period( - flask.request.args.get("period", default="30d", type=str) - ) installed_base_metric = logic.verify_base_metrics( flask.request.args.get("active-devices", default="version", type=str) ) + period = flask.request.args.get("period", default="30d", type=str) + active_device_period = logic.extract_metrics_period(period) + + page = flask.request.args.get("page", default=1, type=int) + + metric_requested_length = active_device_period["int"] + metric_requested_bucket = active_device_period["bucket"] + + page_time_length = flask.request.args.get( + "page-length", default=3, type=int + ) + total_page_num = 1 + if metric_requested_bucket == "d" or ( + metric_requested_bucket == "m" + and page_time_length >= metric_requested_length + ): + dates = metrics_helper.get_dates_for_metric( + metric_requested_length, metric_requested_bucket + ) + start = dates["start"] + end = dates["end"] + else: + page_period_length = ( + (metric_requested_length * 12) + if metric_requested_bucket == "y" + else metric_requested_length + ) + total_page_num = math.floor(page_period_length / page_time_length) + + end = metrics_helper.get_last_metrics_processed_date() + ( + relativedelta.relativedelta( + months=-(page_time_length * (page - 1)) + ) + ) + start = end + (relativedelta.relativedelta(months=-(page_time_length))) + + # Decrease the date by a day to make sure + # there is no overlapping dates across the pages. + if page != 1: + end = end + relativedelta.relativedelta(days=-1) + installed_base = logic.get_installed_based_metric(installed_base_metric) - metrics_query_json = metrics_helper.build_metric_query_installed_base( - snap_id=snap_id, - installed_base=installed_base, - metric_period=metric_requested["int"], - metric_bucket=metric_requested["bucket"], + + new_metrics_query = metrics_helper.build_active_device_metric_query( + snap_id=snap_id, installed_base=installed_base, end=end, start=start ) metrics_response = publisher_api.get_publisher_metrics( - flask.session, json=metrics_query_json + flask.session, json=new_metrics_query ) active_metrics = metrics_helper.find_metric( metrics_response["metrics"], installed_base ) - series = active_metrics["series"] - if active_metrics["metric_name"] == "weekly_installed_base_by_channel": + metrics_data = active_metrics + buckets = metrics_data["buckets"] + series = metrics_data["series"] + metric_name = metrics_data["metric_name"] + # Add constants to a variable + if len(series) > downsample_data_limit: + downsampled_buckets, downsampled_series = ( + metrics_helper.downsample_series( + buckets, series, downsample_target_size + ) + ) + else: + downsampled_buckets = buckets + downsampled_series = series + + series = downsampled_series + if metric_name == "weekly_installed_base_by_channel": for s in series: if "/" not in s["name"]: s["name"] = f"latest/{s['name']}" if installed_base_metric == "os": - capitalized_series = active_metrics["series"] - for item in capitalized_series: + for item in series: item["name"] = metrics._capitalize_os_name(item["name"]) - series = capitalized_series active_devices = metrics.ActiveDevices( - name=active_metrics["metric_name"], + name=metric_name, series=series, - buckets=active_metrics["buckets"], - status=active_metrics["status"], + buckets=downsampled_buckets, + status=metrics_data["status"], + ) + + latest_active = 0 + if active_devices: + latest_active = active_devices.get_number_latest_active_devices() + + return flask.jsonify( + { + "active_devices": dict(active_devices), + "latest_active_devices": latest_active, + "total_page_num": total_page_num, + } ) + +@login_required +def get_latest_active_devices(snap_name): + snap_details = store_api.get_item_details( + snap_name, api_version=2, fields=["snap-id"] + ) + + snap_id = snap_details["snap-id"] # get latest active devices latest_day_period = logic.extract_metrics_period("1d") latest_installed_base = logic.get_installed_based_metric("version") @@ -124,12 +201,12 @@ def get_active_devices(snap_name): metric_period=latest_day_period["int"], metric_bucket=latest_day_period["bucket"], ) + latest_day_response = publisher_api.get_publisher_metrics( flask.session, json=latest_day_query_json ) + latest_active = 0 - if active_devices: - latest_active = active_devices.get_number_latest_active_devices() if latest_day_response: latest_active_metrics = metrics_helper.find_metric( @@ -145,10 +222,8 @@ def get_active_devices(snap_name): latest_active = ( latest_active_devices.get_number_latest_active_devices() ) - return flask.jsonify( { - "active_devices": dict(active_devices), "latest_active_devices": latest_active, } ) diff --git a/webapp/publisher/snaps/views.py b/webapp/publisher/snaps/views.py index 82b6bc1121..40b978a9ac 100644 --- a/webapp/publisher/snaps/views.py +++ b/webapp/publisher/snaps/views.py @@ -216,6 +216,11 @@ view_func=metrics_views.get_active_devices, ) +publisher_snaps.add_url_rule( + "//metrics/active-latest-devices", + view_func=metrics_views.get_latest_active_devices, +) + publisher_snaps.add_url_rule( "//metrics/active-device-annotation", view_func=metrics_views.get_metric_annotaion, From f8dbf3f2e3c92d5daf497cac513b9e70e2aa3e84 Mon Sep 17 00:00:00 2001 From: Steve Rydz Date: Thu, 3 Oct 2024 16:17:20 +0100 Subject: [PATCH 05/24] chore: Move listing page into publisher app (#4865) --- static/js/global.d.ts | 3 + .../SaveAndPreview/SaveAndPreview.tsx | 16 ++-- .../components/SectionNav/SectionNav.tsx | 7 +- .../__tests__/useMutateListingData.test.ts | 4 +- .../hooks/__tests__/useVerified.test.ts | 0 static/js/publisher-pages/hooks/index.ts | 9 +- .../hooks/useMutateListingData.ts | 13 +-- .../hooks/useVerified.ts | 0 static/js/publisher-pages/index.tsx | 5 ++ .../AdditionalInformation.tsx | 4 +- .../AdditionalInformation/LicenseInputs.tsx | 0 .../AdditionalInformation/LicenseSearch.tsx | 0 .../Listing}/AdditionalInformation/index.ts | 0 .../ContactInformation/ContactFields.tsx | 0 .../ContactInformation/ContactInformation.tsx | 4 +- .../ContactInformation/PrimaryDomainInput.tsx | 12 +-- .../__tests__/PrimaryDomainInput.test.tsx | 43 ++++++--- .../Listing}/ContactInformation/index.ts | 0 .../pages/Listing/Listing.tsx} | 25 +++--- .../Listing}/ListingDetails/ImageUpload.tsx | 10 +-- .../ListingDetails/ListingDetails.tsx | 4 +- .../Listing}/ListingDetails/Screenshot.tsx | 0 .../ListingDetails/ScreenshotList.tsx | 0 .../Listing}/ListingDetails/Screenshots.tsx | 2 +- .../pages/Listing}/ListingDetails/index.ts | 0 .../Listing}/ListingForm/ListingForm.tsx | 35 ++++---- .../pages/Listing}/ListingForm/index.ts | 0 .../Listing}/PreviewForm/PreviewForm.tsx | 0 .../pages/Listing}/PreviewForm/index.ts | 0 .../publisher-pages/pages/Listing/index.tsx | 20 +++++ .../pages/Publicise/Publicise.tsx | 90 ++++++++++--------- .../pages/Settings/Settings.tsx | 4 +- static/js/publisher-pages/routes/root.tsx | 2 +- static/js/publisher-pages/test-utils/index.ts | 3 + .../test-utils/mockListingData.ts} | 2 +- static/js/publisher-pages/types/index.d.ts | 37 ++++++++ .../utils/__tests__/addDateToFilename.test.ts | 0 .../__tests__/formatImageChanges.test.ts | 0 .../__tests__/getDefaultListingData.test.ts} | 6 +- .../shouldShowUpdateMetadataWarning.test.ts | 0 .../__tests__/validateImageDimensions.test.ts | 0 .../utils/addDateToFilename.ts | 0 .../utils/formatImageChanges.ts | 0 .../utils/getDefaultListingData.ts} | 6 +- .../utils/getListingChanges.ts} | 6 +- .../{getChanges.ts => getSettingsChanges.ts} | 4 +- ...{getFormData.ts => getSettingsFormData.ts} | 8 +- static/js/publisher-pages/utils/index.ts | 22 ++++- .../utils/shouldShowUpdateMetadataWarning.ts | 0 .../utils/validateImageDimensions.ts | 0 .../publisher/listing/components/App/index.ts | 1 - static/js/publisher/listing/hooks/index.ts | 4 - .../js/publisher/listing/test-utils/index.ts | 3 - static/js/publisher/listing/types/index.d.ts | 45 ---------- static/js/publisher/listing/utils/index.ts | 15 ---- static/js/publisher/tour.tsx | 6 +- static/js/publisher/tour/helpers.ts | 4 +- static/js/publisher/tour/tour.tsx | 4 +- static/js/publisher/tour/tourBar.tsx | 16 ++-- static/js/publisher/tour/tourOverlay.tsx | 4 +- static/js/publisher/tour/tourStepCard.tsx | 4 +- static/sass/_snapcraft_tour.scss | 2 +- static/sass/styles.scss | 4 + templates/publisher/listing.html | 14 --- templates/store/publisher.html | 4 + webapp/publisher/snaps/listing_views.py | 2 +- webpack.config.entry.js | 1 - 67 files changed, 294 insertions(+), 245 deletions(-) rename static/js/{publisher/listing => publisher-pages}/hooks/__tests__/useMutateListingData.test.ts (89%) rename static/js/{publisher/listing => publisher-pages}/hooks/__tests__/useVerified.test.ts (100%) rename static/js/{publisher/listing => publisher-pages}/hooks/useMutateListingData.ts (86%) rename static/js/{publisher/listing => publisher-pages}/hooks/useVerified.ts (100%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/AdditionalInformation/AdditionalInformation.tsx (97%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/AdditionalInformation/LicenseInputs.tsx (100%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/AdditionalInformation/LicenseSearch.tsx (100%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/AdditionalInformation/index.ts (100%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ContactInformation/ContactFields.tsx (100%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ContactInformation/ContactInformation.tsx (95%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ContactInformation/PrimaryDomainInput.tsx (97%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ContactInformation/__tests__/PrimaryDomainInput.test.tsx (87%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ContactInformation/index.ts (100%) rename static/js/{publisher/listing/components/App/App.tsx => publisher-pages/pages/Listing/Listing.tsx} (61%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ListingDetails/ImageUpload.tsx (96%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ListingDetails/ListingDetails.tsx (99%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ListingDetails/Screenshot.tsx (100%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ListingDetails/ScreenshotList.tsx (100%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ListingDetails/Screenshots.tsx (98%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ListingDetails/index.ts (100%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ListingForm/ListingForm.tsx (86%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/ListingForm/index.ts (100%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/PreviewForm/PreviewForm.tsx (100%) rename static/js/{publisher/listing/components => publisher-pages/pages/Listing}/PreviewForm/index.ts (100%) create mode 100644 static/js/publisher-pages/pages/Listing/index.tsx create mode 100644 static/js/publisher-pages/test-utils/index.ts rename static/js/{publisher/listing/test-utils/mockData.ts => publisher-pages/test-utils/mockListingData.ts} (97%) rename static/js/{publisher/listing => publisher-pages}/utils/__tests__/addDateToFilename.test.ts (100%) rename static/js/{publisher/listing => publisher-pages}/utils/__tests__/formatImageChanges.test.ts (100%) rename static/js/{publisher/listing/utils/__tests__/getDefaultData.test.ts => publisher-pages/utils/__tests__/getDefaultListingData.test.ts} (89%) rename static/js/{publisher/listing => publisher-pages}/utils/__tests__/shouldShowUpdateMetadataWarning.test.ts (100%) rename static/js/{publisher/listing => publisher-pages}/utils/__tests__/validateImageDimensions.test.ts (100%) rename static/js/{publisher/listing => publisher-pages}/utils/addDateToFilename.ts (100%) rename static/js/{publisher/listing => publisher-pages}/utils/formatImageChanges.ts (100%) rename static/js/{publisher/listing/utils/getDefaultData.ts => publisher-pages/utils/getDefaultListingData.ts} (92%) rename static/js/{publisher/listing/utils/getChanges.ts => publisher-pages/utils/getListingChanges.ts} (96%) rename static/js/publisher-pages/utils/{getChanges.ts => getSettingsChanges.ts} (92%) rename static/js/publisher-pages/utils/{getFormData.ts => getSettingsFormData.ts} (90%) rename static/js/{publisher/listing => publisher-pages}/utils/shouldShowUpdateMetadataWarning.ts (100%) rename static/js/{publisher/listing => publisher-pages}/utils/validateImageDimensions.ts (100%) delete mode 100644 static/js/publisher/listing/components/App/index.ts delete mode 100644 static/js/publisher/listing/hooks/index.ts delete mode 100644 static/js/publisher/listing/test-utils/index.ts delete mode 100644 static/js/publisher/listing/types/index.d.ts delete mode 100644 static/js/publisher/listing/utils/index.ts delete mode 100644 templates/publisher/listing.html diff --git a/static/js/global.d.ts b/static/js/global.d.ts index 251ac12813..3ce309e21e 100644 --- a/static/js/global.d.ts +++ b/static/js/global.d.ts @@ -44,4 +44,7 @@ declare interface Window { whitelist_countries: string[]; whitelist_country_keys: string; }; + SNAP_LISTING_DATA: { + DNS_VERIFICATION_TOKEN: string; + }; } diff --git a/static/js/publisher-pages/components/SaveAndPreview/SaveAndPreview.tsx b/static/js/publisher-pages/components/SaveAndPreview/SaveAndPreview.tsx index a6b71a5685..eb2eb093ed 100644 --- a/static/js/publisher-pages/components/SaveAndPreview/SaveAndPreview.tsx +++ b/static/js/publisher-pages/components/SaveAndPreview/SaveAndPreview.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useRef } from "react"; import { Row, Col, Button } from "@canonical/react-components"; import debounce from "../../../libs/debounce"; @@ -20,6 +20,8 @@ function SaveAndPreview({ showPreview, }: Props) { const stickyBar = useRef(null); + const mainPanel = document.querySelector(".l-main") as HTMLElement; + const handleScroll = () => { stickyBar?.current?.classList.toggle( "sticky-shadow", @@ -27,13 +29,17 @@ function SaveAndPreview({ ); }; - useEffect(() => { - document.addEventListener("scroll", debounce(handleScroll, 10, false)); - }, []); + if (mainPanel) { + mainPanel.addEventListener("scroll", debounce(handleScroll, 10, false)); + } return ( <> -
+

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

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

+ My snaps / {snapId} / + Listing +

+ + {isLoading && (

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

)} @@ -48,4 +49,4 @@ function App(): JSX.Element { ); } -export default App; +export default Listing; diff --git a/static/js/publisher/listing/components/ListingDetails/ImageUpload.tsx b/static/js/publisher-pages/pages/Listing/ListingDetails/ImageUpload.tsx similarity index 96% rename from static/js/publisher/listing/components/ListingDetails/ImageUpload.tsx rename to static/js/publisher-pages/pages/Listing/ListingDetails/ImageUpload.tsx index def3c8ce85..a33010bfa8 100644 --- a/static/js/publisher/listing/components/ListingDetails/ImageUpload.tsx +++ b/static/js/publisher-pages/pages/Listing/ListingDetails/ImageUpload.tsx @@ -9,7 +9,7 @@ import { Icon, } from "@canonical/react-components"; -import { validateImageDimensions } from "../../utils"; +import { validateImageDimensions } from "../../../utils"; type Props = { imageUrl: string | null; @@ -236,12 +236,8 @@ function ImageUpload({ className="p-button--base snap-remove-icon" onClick={() => { setImageIsValid(true); - setValue(imageUrlFieldKey, "", { - shouldDirty: window?.listingData?.banner_urls[0] !== null, - }); - setValue(imageFieldKey, new File([], ""), { - shouldDirty: window?.listingData?.banner_urls[0] !== null, - }); + setValue(imageUrlFieldKey, ""); + setValue(imageFieldKey, new File([], "")); setPreviewImageUrl(""); }} > diff --git a/static/js/publisher/listing/components/ListingDetails/ListingDetails.tsx b/static/js/publisher-pages/pages/Listing/ListingDetails/ListingDetails.tsx similarity index 99% rename from static/js/publisher/listing/components/ListingDetails/ListingDetails.tsx rename to static/js/publisher-pages/pages/Listing/ListingDetails/ListingDetails.tsx index 3835ab4626..9a39e99bed 100644 --- a/static/js/publisher/listing/components/ListingDetails/ListingDetails.tsx +++ b/static/js/publisher-pages/pages/Listing/ListingDetails/ListingDetails.tsx @@ -11,10 +11,10 @@ import { Row, Col, Button, Icon } from "@canonical/react-components"; import ImageUpload from "./ImageUpload"; import Screenshots from "./Screenshots"; -import type { Data } from "../../types"; +import type { ListingData } from "../../../types"; type Props = { - data: Data; + data: ListingData; register: UseFormRegister; getValues: UseFormGetValues; setValue: UseFormSetValue; diff --git a/static/js/publisher/listing/components/ListingDetails/Screenshot.tsx b/static/js/publisher-pages/pages/Listing/ListingDetails/Screenshot.tsx similarity index 100% rename from static/js/publisher/listing/components/ListingDetails/Screenshot.tsx rename to static/js/publisher-pages/pages/Listing/ListingDetails/Screenshot.tsx diff --git a/static/js/publisher/listing/components/ListingDetails/ScreenshotList.tsx b/static/js/publisher-pages/pages/Listing/ListingDetails/ScreenshotList.tsx similarity index 100% rename from static/js/publisher/listing/components/ListingDetails/ScreenshotList.tsx rename to static/js/publisher-pages/pages/Listing/ListingDetails/ScreenshotList.tsx diff --git a/static/js/publisher/listing/components/ListingDetails/Screenshots.tsx b/static/js/publisher-pages/pages/Listing/ListingDetails/Screenshots.tsx similarity index 98% rename from static/js/publisher/listing/components/ListingDetails/Screenshots.tsx rename to static/js/publisher-pages/pages/Listing/ListingDetails/Screenshots.tsx index 83104d5109..8db14214a7 100644 --- a/static/js/publisher/listing/components/ListingDetails/Screenshots.tsx +++ b/static/js/publisher-pages/pages/Listing/ListingDetails/Screenshots.tsx @@ -3,7 +3,7 @@ import { useFieldArray } from "react-hook-form"; import { nanoid } from "nanoid"; import { Row, Col, Notification } from "@canonical/react-components"; -import { validateImageDimensions } from "../../utils"; +import { validateImageDimensions } from "../../../utils"; import ScreenshotList from "./ScreenshotList"; diff --git a/static/js/publisher/listing/components/ListingDetails/index.ts b/static/js/publisher-pages/pages/Listing/ListingDetails/index.ts similarity index 100% rename from static/js/publisher/listing/components/ListingDetails/index.ts rename to static/js/publisher-pages/pages/Listing/ListingDetails/index.ts diff --git a/static/js/publisher/listing/components/ListingForm/ListingForm.tsx b/static/js/publisher-pages/pages/Listing/ListingForm/ListingForm.tsx similarity index 86% rename from static/js/publisher/listing/components/ListingForm/ListingForm.tsx rename to static/js/publisher-pages/pages/Listing/ListingForm/ListingForm.tsx index 8117761831..90e3051d3c 100644 --- a/static/js/publisher/listing/components/ListingForm/ListingForm.tsx +++ b/static/js/publisher-pages/pages/Listing/ListingForm/ListingForm.tsx @@ -3,27 +3,30 @@ import { useParams } from "react-router-dom"; import { useForm, useFormState, FieldValues } from "react-hook-form"; import { Strip, Notification } from "@canonical/react-components"; -import SaveAndPreview from "../../../shared/SaveAndPreview"; +import SaveAndPreview from "../../../components/SaveAndPreview"; import ListingDetails from "../ListingDetails"; import ContactInformation from "../ContactInformation"; import AdditionalInformation from "../AdditionalInformation"; import PreviewForm from "../PreviewForm"; -import UpdateMetadataModal from "../../../shared/UpdateMetadataModal"; +import UpdateMetadataModal from "../../../components/UpdateMetadataModal"; -import { shouldShowUpdateMetadataWarning, getDefaultData } from "../../utils"; -import { initListingTour } from "../../../tour"; +import { + shouldShowUpdateMetadataWarning, + getDefaultListingData, +} from "../../../utils"; +import { initListingTour } from "../../../../publisher/tour"; -import { useMutateListingData } from "../../hooks"; +import { useMutateListingData } from "../../../hooks"; -import type { Data } from "../../types"; +import type { ListingData } from "../../../types"; type Props = { - data: Data; + data: ListingData; refetch: Function; }; function ListingForm({ data, refetch }: Props): JSX.Element { - const { snapName } = useParams(); + const { snapId } = useParams(); const { register, @@ -36,7 +39,7 @@ function ListingForm({ data, refetch }: Props): JSX.Element { handleSubmit, watch, } = useForm({ - defaultValues: getDefaultData(data), + defaultValues: getDefaultListingData(data), }); const { dirtyFields } = useFormState({ control }); @@ -57,13 +60,13 @@ function ListingForm({ data, refetch }: Props): JSX.Element { const { mutate, isLoading } = useMutateListingData({ data, dirtyFields, - getDefaultData, + getDefaultData: getDefaultListingData, refetch, reset, setShowSuccessNotification, setUpdateMetadataOnRelease, shouldShowUpdateMetadataWarning, - snapName, + snapName: snapId, }); useEffect(() => { @@ -71,13 +74,13 @@ function ListingForm({ data, refetch }: Props): JSX.Element { "tour-container", ) as HTMLElement; - if (snapName) { + if (snapId) { initListingTour({ - snapName, + snapName: snapId, container: tourContainer, formFields: { title: data.title, - snap_name: snapName, + snap_name: snapId, categories: [], video_urls: [], images: [], @@ -107,7 +110,7 @@ function ListingForm({ data, refetch }: Props): JSX.Element { })} > - {snapName && } + {snapId && }
); diff --git a/static/js/publisher/listing/components/ListingForm/index.ts b/static/js/publisher-pages/pages/Listing/ListingForm/index.ts similarity index 100% rename from static/js/publisher/listing/components/ListingForm/index.ts rename to static/js/publisher-pages/pages/Listing/ListingForm/index.ts diff --git a/static/js/publisher/listing/components/PreviewForm/PreviewForm.tsx b/static/js/publisher-pages/pages/Listing/PreviewForm/PreviewForm.tsx similarity index 100% rename from static/js/publisher/listing/components/PreviewForm/PreviewForm.tsx rename to static/js/publisher-pages/pages/Listing/PreviewForm/PreviewForm.tsx diff --git a/static/js/publisher/listing/components/PreviewForm/index.ts b/static/js/publisher-pages/pages/Listing/PreviewForm/index.ts similarity index 100% rename from static/js/publisher/listing/components/PreviewForm/index.ts rename to static/js/publisher-pages/pages/Listing/PreviewForm/index.ts diff --git a/static/js/publisher-pages/pages/Listing/index.tsx b/static/js/publisher-pages/pages/Listing/index.tsx new file mode 100644 index 0000000000..f7078049ef --- /dev/null +++ b/static/js/publisher-pages/pages/Listing/index.tsx @@ -0,0 +1,20 @@ +// import { createRoot } from "react-dom/client"; +// import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; +// import { QueryClient, QueryClientProvider } from "react-query"; +// import App from "./components/App"; + +// const queryClient = new QueryClient(); + +// const container = document.getElementById("main-content"); +// const root = createRoot(container as HTMLElement); +// root.render( +// +// +// +// } /> +// +// +// +// ); + +export { default } from "./Listing"; diff --git a/static/js/publisher-pages/pages/Publicise/Publicise.tsx b/static/js/publisher-pages/pages/Publicise/Publicise.tsx index 770f4222c0..71c693dc6a 100644 --- a/static/js/publisher-pages/pages/Publicise/Publicise.tsx +++ b/static/js/publisher-pages/pages/Publicise/Publicise.tsx @@ -1,8 +1,9 @@ -import { useParams, NavLink, Link } from "react-router-dom"; +import { useParams, NavLink } from "react-router-dom"; import { Row, Col, SideNavigation, + Strip, Notification, } from "@canonical/react-components"; @@ -39,49 +40,50 @@ function Publicise({ view }: Props): JSX.Element { - {disableView() && ( - - When your snap is public and has a release, you'll be able to share it - using Store buttons, badges and embeddable cards. Make your snap - public in its settings page. - - )} - - - - - - - {!view && } - {view === "badges" && } - {view === "cards" && } - - + + {disableView() && ( + + When your snap is public and has a release, you'll be able to share + it using Store buttons, badges and embeddable cards. Make your snap + public in its settings page. + + )} + + + + + + {!view && } + {view === "badges" && } + {view === "cards" && } + + + ); } diff --git a/static/js/publisher-pages/pages/Settings/Settings.tsx b/static/js/publisher-pages/pages/Settings/Settings.tsx index fb75796822..07f2907545 100644 --- a/static/js/publisher-pages/pages/Settings/Settings.tsx +++ b/static/js/publisher-pages/pages/Settings/Settings.tsx @@ -18,7 +18,7 @@ import UpdateMetadataModal from "../../components/UpdateMetadataModal"; import SaveStateNotifications from "../../components/SaveStateNotifications"; import { UnregisterSnapModal } from "./UnregisterSnapModal"; -import { getSettingsData, getFormData } from "../../utils"; +import { getSettingsData, getSettingsFormData } from "../../utils"; function Settings() { const { snapId } = useParams(); @@ -92,7 +92,7 @@ function Settings() { const response = await fetch(`/${data.snap_name}/settings.json`, { method: "POST", - body: getFormData(settingsData, dirtyFields, data), + body: getSettingsFormData(settingsData, dirtyFields, data), }); if (response.status !== 200) { diff --git a/static/js/publisher-pages/routes/root.tsx b/static/js/publisher-pages/routes/root.tsx index 77e878463f..6fe58ce9b5 100644 --- a/static/js/publisher-pages/routes/root.tsx +++ b/static/js/publisher-pages/routes/root.tsx @@ -25,7 +25,7 @@ function Root(): JSX.Element { /> } > -
+
diff --git a/static/js/publisher-pages/test-utils/index.ts b/static/js/publisher-pages/test-utils/index.ts new file mode 100644 index 0000000000..dc097f6463 --- /dev/null +++ b/static/js/publisher-pages/test-utils/index.ts @@ -0,0 +1,3 @@ +import { mockListingData } from "./mockListingData"; + +export { mockListingData }; diff --git a/static/js/publisher/listing/test-utils/mockData.ts b/static/js/publisher-pages/test-utils/mockListingData.ts similarity index 97% rename from static/js/publisher/listing/test-utils/mockData.ts rename to static/js/publisher-pages/test-utils/mockListingData.ts index 75ad1d48b5..467bcd7ff9 100644 --- a/static/js/publisher/listing/test-utils/mockData.ts +++ b/static/js/publisher-pages/test-utils/mockListingData.ts @@ -1,4 +1,4 @@ -export const mockData = { +export const mockListingData = { banner_urls: ["https://example.com/screenshot"], categories: [ { name: "Test category 1", slug: "test-category-1" }, diff --git a/static/js/publisher-pages/types/index.d.ts b/static/js/publisher-pages/types/index.d.ts index 922a6c6927..aa2e270670 100644 --- a/static/js/publisher-pages/types/index.d.ts +++ b/static/js/publisher-pages/types/index.d.ts @@ -33,3 +33,40 @@ export type SettingsData = { whitelist_countries: string[]; whitelist_country_keys: string; }; + +export type TourStep = { + id: string; + position?: string; + elements?: HTMLElement[]; + title: string; + content: string; +}; + +export type ListingData = { + snap_id: string; + title: string; + video_urls: string; + summary: string; + description: string; + categories: { name: string; slug: string }[]; + primary_category: string; + secondary_category: string; + websites: { url: string }[]; + contacts: { url: string }[]; + donations: { url: string }[]; + source_code: { url: string }[]; + issues: { url: string }[]; + primary_website: string; + public_metrics_enabled: boolean; + public_metrics_blacklist: string[]; + public_metrics_territories: boolean; + public_metrics_distros: boolean; + license: string; + license_type: string; + licenses: { key: string; name: string }[]; + icon_url: string; + screenshot_urls: string[]; + banner_urls: string[]; + update_metadata_on_release: boolean; + tour_steps: Step[]; +}; diff --git a/static/js/publisher/listing/utils/__tests__/addDateToFilename.test.ts b/static/js/publisher-pages/utils/__tests__/addDateToFilename.test.ts similarity index 100% rename from static/js/publisher/listing/utils/__tests__/addDateToFilename.test.ts rename to static/js/publisher-pages/utils/__tests__/addDateToFilename.test.ts diff --git a/static/js/publisher/listing/utils/__tests__/formatImageChanges.test.ts b/static/js/publisher-pages/utils/__tests__/formatImageChanges.test.ts similarity index 100% rename from static/js/publisher/listing/utils/__tests__/formatImageChanges.test.ts rename to static/js/publisher-pages/utils/__tests__/formatImageChanges.test.ts diff --git a/static/js/publisher/listing/utils/__tests__/getDefaultData.test.ts b/static/js/publisher-pages/utils/__tests__/getDefaultListingData.test.ts similarity index 89% rename from static/js/publisher/listing/utils/__tests__/getDefaultData.test.ts rename to static/js/publisher-pages/utils/__tests__/getDefaultListingData.test.ts index 964cd7222e..058788c9ef 100644 --- a/static/js/publisher/listing/utils/__tests__/getDefaultData.test.ts +++ b/static/js/publisher-pages/utils/__tests__/getDefaultListingData.test.ts @@ -1,10 +1,10 @@ -import getDefaultData from "../getDefaultData"; +import getDefaultListingData from "../getDefaultListingData"; -import { mockData } from "../../test-utils"; +import { mockListingData } from "../../test-utils"; describe("getDefaultData", () => { test("returns default data", () => { - const defaultData = getDefaultData(mockData); + const defaultData = getDefaultListingData(mockListingData); expect(defaultData.contacts).toBeDefined(); expect(defaultData.description).toBeDefined(); diff --git a/static/js/publisher/listing/utils/__tests__/shouldShowUpdateMetadataWarning.test.ts b/static/js/publisher-pages/utils/__tests__/shouldShowUpdateMetadataWarning.test.ts similarity index 100% rename from static/js/publisher/listing/utils/__tests__/shouldShowUpdateMetadataWarning.test.ts rename to static/js/publisher-pages/utils/__tests__/shouldShowUpdateMetadataWarning.test.ts diff --git a/static/js/publisher/listing/utils/__tests__/validateImageDimensions.test.ts b/static/js/publisher-pages/utils/__tests__/validateImageDimensions.test.ts similarity index 100% rename from static/js/publisher/listing/utils/__tests__/validateImageDimensions.test.ts rename to static/js/publisher-pages/utils/__tests__/validateImageDimensions.test.ts diff --git a/static/js/publisher/listing/utils/addDateToFilename.ts b/static/js/publisher-pages/utils/addDateToFilename.ts similarity index 100% rename from static/js/publisher/listing/utils/addDateToFilename.ts rename to static/js/publisher-pages/utils/addDateToFilename.ts diff --git a/static/js/publisher/listing/utils/formatImageChanges.ts b/static/js/publisher-pages/utils/formatImageChanges.ts similarity index 100% rename from static/js/publisher/listing/utils/formatImageChanges.ts rename to static/js/publisher-pages/utils/formatImageChanges.ts diff --git a/static/js/publisher/listing/utils/getDefaultData.ts b/static/js/publisher-pages/utils/getDefaultListingData.ts similarity index 92% rename from static/js/publisher/listing/utils/getDefaultData.ts rename to static/js/publisher-pages/utils/getDefaultListingData.ts index c1bc79e3ac..5afe6aca79 100644 --- a/static/js/publisher/listing/utils/getDefaultData.ts +++ b/static/js/publisher-pages/utils/getDefaultListingData.ts @@ -1,4 +1,4 @@ -import type { Data } from "../types"; +import type { ListingData } from "../types"; function getPublicMetricsTerritoriesValue( publicMetricsBlacklist: string[], @@ -36,7 +36,9 @@ function getPublicMetricsDistrosValue( return false; } -export default function getDefaultData(data: Data): { [key: string]: any } { +export default function getDefaultListingData(data: ListingData): { + [key: string]: any; +} { return { contacts: data.contacts, description: data.description, diff --git a/static/js/publisher/listing/utils/getChanges.ts b/static/js/publisher-pages/utils/getListingChanges.ts similarity index 96% rename from static/js/publisher/listing/utils/getChanges.ts rename to static/js/publisher-pages/utils/getListingChanges.ts index f623c7aba2..34aad3744e 100644 --- a/static/js/publisher/listing/utils/getChanges.ts +++ b/static/js/publisher-pages/utils/getListingChanges.ts @@ -1,11 +1,11 @@ import formatImageChanges from "./formatImageChanges"; -import type { Data } from "../types"; +import type { ListingData } from "../types"; -export default function getChanges( +export default function getListingChanges( dirtyFields: any, fieldValues: any, - data: Data, + data: ListingData, ): { [key: string]: any } { const changes: { [key: string]: any } = {}; diff --git a/static/js/publisher-pages/utils/getChanges.ts b/static/js/publisher-pages/utils/getSettingsChanges.ts similarity index 92% rename from static/js/publisher-pages/utils/getChanges.ts rename to static/js/publisher-pages/utils/getSettingsChanges.ts index 8625ce7e37..26fd5ad7b4 100644 --- a/static/js/publisher-pages/utils/getChanges.ts +++ b/static/js/publisher-pages/utils/getSettingsChanges.ts @@ -1,4 +1,4 @@ -function getChanges(dirtyFields: { [key: string]: any }, data: any) { +function getSettingsChanges(dirtyFields: { [key: string]: any }, data: any) { const changes: { [key: string]: any } = {}; if (dirtyFields?.visibility) { @@ -58,4 +58,4 @@ function getChanges(dirtyFields: { [key: string]: any }, data: any) { return changes; } -export default getChanges; +export default getSettingsChanges; diff --git a/static/js/publisher-pages/utils/getFormData.ts b/static/js/publisher-pages/utils/getSettingsFormData.ts similarity index 90% rename from static/js/publisher-pages/utils/getFormData.ts rename to static/js/publisher-pages/utils/getSettingsFormData.ts index 06c5762d2e..93a264d006 100644 --- a/static/js/publisher-pages/utils/getFormData.ts +++ b/static/js/publisher-pages/utils/getSettingsFormData.ts @@ -1,13 +1,13 @@ -import getChanges from "./getChanges"; +import getSettingsChanges from "./getSettingsChanges"; import type { SettingsData } from "../types"; -function getFormData( +function getSettingsFormData( settingsData: SettingsData, dirtyFields: { [key: string]: any }, data: any, ) { - const changes = getChanges(dirtyFields, data); + const changes = getSettingsChanges(dirtyFields, data); const formData = new FormData(); formData.set("csrf_token", window.CSRF_TOKEN); @@ -51,4 +51,4 @@ function getFormData( return formData; } -export default getFormData; +export default getSettingsFormData; diff --git a/static/js/publisher-pages/utils/index.ts b/static/js/publisher-pages/utils/index.ts index 0deb89bf75..6831b29c2a 100644 --- a/static/js/publisher-pages/utils/index.ts +++ b/static/js/publisher-pages/utils/index.ts @@ -1,5 +1,21 @@ import getSettingsData from "./getSettingsData"; -import getChanges from "./getChanges"; -import getFormData from "./getFormData"; +import getSettingsChanges from "./getSettingsChanges"; +import getSettingsFormData from "./getSettingsFormData"; +import getListingChanges from "./getListingChanges"; +import formatImageChanges from "./formatImageChanges"; +import getDefaultListingData from "./getDefaultListingData"; +import shouldShowUpdateMetadataWarning from "./shouldShowUpdateMetadataWarning"; +import validateImageDimensions from "./validateImageDimensions"; +import addDateToFilename from "./addDateToFilename"; -export { getSettingsData, getChanges, getFormData }; +export { + getSettingsData, + getSettingsChanges, + getSettingsFormData, + getListingChanges, + formatImageChanges, + getDefaultListingData, + shouldShowUpdateMetadataWarning, + validateImageDimensions, + addDateToFilename, +}; diff --git a/static/js/publisher/listing/utils/shouldShowUpdateMetadataWarning.ts b/static/js/publisher-pages/utils/shouldShowUpdateMetadataWarning.ts similarity index 100% rename from static/js/publisher/listing/utils/shouldShowUpdateMetadataWarning.ts rename to static/js/publisher-pages/utils/shouldShowUpdateMetadataWarning.ts diff --git a/static/js/publisher/listing/utils/validateImageDimensions.ts b/static/js/publisher-pages/utils/validateImageDimensions.ts similarity index 100% rename from static/js/publisher/listing/utils/validateImageDimensions.ts rename to static/js/publisher-pages/utils/validateImageDimensions.ts diff --git a/static/js/publisher/listing/components/App/index.ts b/static/js/publisher/listing/components/App/index.ts deleted file mode 100644 index 8ce017e646..0000000000 --- a/static/js/publisher/listing/components/App/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./App"; diff --git a/static/js/publisher/listing/hooks/index.ts b/static/js/publisher/listing/hooks/index.ts deleted file mode 100644 index bd59f1cd25..0000000000 --- a/static/js/publisher/listing/hooks/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import useVerified from "./useVerified"; -import useMutateListingData from "./useMutateListingData"; - -export { useVerified, useMutateListingData }; diff --git a/static/js/publisher/listing/test-utils/index.ts b/static/js/publisher/listing/test-utils/index.ts deleted file mode 100644 index 5da2429913..0000000000 --- a/static/js/publisher/listing/test-utils/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { mockData } from "./mockData"; - -export { mockData }; diff --git a/static/js/publisher/listing/types/index.d.ts b/static/js/publisher/listing/types/index.d.ts deleted file mode 100644 index 37cd8c4e13..0000000000 --- a/static/js/publisher/listing/types/index.d.ts +++ /dev/null @@ -1,45 +0,0 @@ -declare global { - interface Window { - SENTRY_DSN: string; - CSRF_TOKEN: string; - listingData: any; - tourSteps: any; - } -} - -export type Step = { - id: string; - position?: string; - elements?: HTMLElement[]; - title: string; - content: string; -}; - -export type Data = { - snap_id: string; - title: string; - video_urls: string; - summary: string; - description: string; - categories: { name: string; slug: string }[]; - primary_category: string; - secondary_category: string; - websites: { url: string }[]; - contacts: { url: string }[]; - donations: { url: string }[]; - source_code: { url: string }[]; - issues: { url: string }[]; - primary_website: string; - public_metrics_enabled: boolean; - public_metrics_blacklist: string[]; - public_metrics_territories: boolean; - public_metrics_distros: boolean; - license: string; - license_type: string; - licenses: { key: string; name: string }[]; - icon_url: string; - screenshot_urls: string[]; - banner_urls: string[]; - update_metadata_on_release: boolean; - tour_steps: Step[]; -}; diff --git a/static/js/publisher/listing/utils/index.ts b/static/js/publisher/listing/utils/index.ts deleted file mode 100644 index d7a5f6555d..0000000000 --- a/static/js/publisher/listing/utils/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import validateImageDimensions from "./validateImageDimensions"; -import shouldShowUpdateMetadataWarning from "./shouldShowUpdateMetadataWarning"; -import addDateToFilename from "./addDateToFilename"; -import formatImageChanges from "./formatImageChanges"; -import getChanges from "./getChanges"; -import getDefaultData from "./getDefaultData"; - -export { - validateImageDimensions, - shouldShowUpdateMetadataWarning, - addDateToFilename, - formatImageChanges, - getChanges, - getDefaultData, -}; diff --git a/static/js/publisher/tour.tsx b/static/js/publisher/tour.tsx index 0b2b5bab35..3af2d4ade6 100644 --- a/static/js/publisher/tour.tsx +++ b/static/js/publisher/tour.tsx @@ -4,7 +4,7 @@ import Tour from "./tour/tour"; import { toggleShadowWhenSticky } from "./market/stickyListingBar"; -import type { Step } from "./listing/types"; +import type { TourStep } from "../publisher-pages/types"; // returns true if % of truthy values in the array is above the threshold function isCompleted(fields: unknown[], threshold = 0.5): boolean { @@ -21,7 +21,7 @@ export function initTour({ startTour, }: { container: HTMLElement; - steps: Step[]; + steps: TourStep[]; onTourStarted: () => void; onTourClosed: () => void; startTour: boolean; @@ -52,7 +52,7 @@ export function initListingTour({ }: { snapName: string; container: HTMLElement; - steps: Step[]; + steps: TourStep[]; formFields: { title: string; snap_name: string; diff --git a/static/js/publisher/tour/helpers.ts b/static/js/publisher/tour/helpers.ts index 79be18f406..ce4bc6da7a 100644 --- a/static/js/publisher/tour/helpers.ts +++ b/static/js/publisher/tour/helpers.ts @@ -1,6 +1,6 @@ import { MASK_OFFSET } from "./constants"; -import type { Step } from "../listing/types"; +import type { TourStep } from "../../publisher-pages/types"; // check if element is part of the DOM and is visible export const isVisibleInDocument = (el: HTMLElement): boolean => @@ -8,7 +8,7 @@ export const isVisibleInDocument = (el: HTMLElement): boolean => // find DOM elements for each step, ignore steps with no elements // set default position to "bottom-left" -export function prepareSteps(steps: Step[]): Array<{ +export function prepareSteps(steps: TourStep[]): Array<{ id: string; position: string; elements: HTMLElement[]; diff --git a/static/js/publisher/tour/tour.tsx b/static/js/publisher/tour/tour.tsx index 6978f02aa7..bf7d8fc4b6 100644 --- a/static/js/publisher/tour/tour.tsx +++ b/static/js/publisher/tour/tour.tsx @@ -5,10 +5,10 @@ import TourBar from "./tourBar"; import { tourStartedAutomatically } from "./metricsEvents"; -import type { Step } from "../listing/types"; +import type { TourStep } from "../../publisher-pages/types"; type Props = { - steps: Step[]; + steps: TourStep[]; startTour: boolean; onTourStarted: () => void; onTourClosed: () => void; diff --git a/static/js/publisher/tour/tourBar.tsx b/static/js/publisher/tour/tourBar.tsx index d0b30bca1e..a76cdcf3b7 100644 --- a/static/js/publisher/tour/tourBar.tsx +++ b/static/js/publisher/tour/tourBar.tsx @@ -8,15 +8,13 @@ export default function TourBar({ showTour }: { showTour: () => void }) { return (
-
- -
+
); } diff --git a/static/js/publisher/tour/tourOverlay.tsx b/static/js/publisher/tour/tourOverlay.tsx index 64d918ba23..6fb5673c86 100644 --- a/static/js/publisher/tour/tourOverlay.tsx +++ b/static/js/publisher/tour/tourOverlay.tsx @@ -14,14 +14,14 @@ import { import { animateScrollTo } from "../../public/scroll-to"; -import type { Step } from "../listing/types"; +import type { TourStep } from "../../publisher-pages/types"; export default function TourOverlay({ steps, hideTour, currentStepIndex = 0, }: { - steps: Step[]; + steps: TourStep[]; hideTour: () => void; currentStepIndex?: number; }) { diff --git a/static/js/publisher/tour/tourStepCard.tsx b/static/js/publisher/tour/tourStepCard.tsx index 2b4865bac2..7c8cb9c188 100644 --- a/static/js/publisher/tour/tourStepCard.tsx +++ b/static/js/publisher/tour/tourStepCard.tsx @@ -1,9 +1,9 @@ import { ReactNode } from "react"; -import type { Step } from "../listing/types"; +import type { TourStep } from "../../publisher-pages/types"; type Props = { - steps: Step[]; + steps: TourStep[]; currentStepIndex: number; mask: { top: number; diff --git a/static/sass/_snapcraft_tour.scss b/static/sass/_snapcraft_tour.scss index 8b3b88dd43..417c2fabb8 100644 --- a/static/sass/_snapcraft_tour.scss +++ b/static/sass/_snapcraft_tour.scss @@ -13,7 +13,7 @@ .p-tour-bar { bottom: 0; left: 0; - padding: $spv--medium; + padding: $spv--medium $spv--x-large; pointer-events: none; position: fixed; right: 0; diff --git a/static/sass/styles.scss b/static/sass/styles.scss index 71ac988599..5ede466624 100644 --- a/static/sass/styles.scss +++ b/static/sass/styles.scss @@ -568,3 +568,7 @@ dl { flex-grow: 1; } } + +.publisher-app > .p-panel > .p-panel__content { + overflow: visible; +} diff --git a/templates/publisher/listing.html b/templates/publisher/listing.html deleted file mode 100644 index 27a6e41595..0000000000 --- a/templates/publisher/listing.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "publisher/_publisher_layout.html" %} - -{% block meta_title %} -Listing details for {{ snap_name }} -{% endblock %} - -{% block content %} -
- - -{% endblock %} diff --git a/templates/store/publisher.html b/templates/store/publisher.html index 95b7c4c29a..40d63e6288 100644 --- a/templates/store/publisher.html +++ b/templates/store/publisher.html @@ -28,6 +28,10 @@ blacklist_countries: {% if blacklist_country_codes %}{{ blacklist_country_codes|tojson }}{% else %}[]{% endif %}, visibility_locked: {% if visibility_locked %}{{ visibility_locked|tojson }}{% else %}false{% endif %}, } + + window.SNAP_LISTING_DATA = { + DNS_VERIFICATION_TOKEN: "{{ dns_verification_token }}", + }; {% endblock %} diff --git a/webapp/publisher/snaps/listing_views.py b/webapp/publisher/snaps/listing_views.py index 38dde97413..1ce3a6bb83 100644 --- a/webapp/publisher/snaps/listing_views.py +++ b/webapp/publisher/snaps/listing_views.py @@ -166,7 +166,7 @@ def get_listing_snap(snap_name): snap_details["snap_name"], snap_details["links"]["website"][0] ) return flask.render_template( - "publisher/listing.html", + "store/publisher.html", snap_name=snap_name, dns_verification_token=token, ) diff --git a/webpack.config.entry.js b/webpack.config.entry.js index 0bdcf04789..edf1d75f05 100644 --- a/webpack.config.entry.js +++ b/webpack.config.entry.js @@ -17,7 +17,6 @@ module.exports = { "distro-install": "./static/js/public/distro-install.ts", "publisher-details": "./static/js/public/publisher-details.ts", "brand-store": "./static/js/brand-store/brand-store.tsx", - "publisher-listing": "./static/js/publisher/listing/index.tsx", "about-listing": "./static/js/public/about/listing.ts", store: "./static/js/store/index.tsx", "publisher-pages": "./static/js/publisher-pages/index.tsx", From 2d1670a453de99d8e4ac66e8ed3399ddbcd41876 Mon Sep 17 00:00:00 2001 From: ilayda-cp Date: Tue, 8 Oct 2024 13:47:12 +0300 Subject: [PATCH 06/24] feat: add breadcrumb nav to the metrics page (#4871) --- static/js/publisher-pages/pages/Metrics/Metrics.tsx | 7 ++++++- static/sass/_snapcraft_metrics.scss | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/static/js/publisher-pages/pages/Metrics/Metrics.tsx b/static/js/publisher-pages/pages/Metrics/Metrics.tsx index c4ba0a9db6..cad0eae341 100644 --- a/static/js/publisher-pages/pages/Metrics/Metrics.tsx +++ b/static/js/publisher-pages/pages/Metrics/Metrics.tsx @@ -8,7 +8,7 @@ import { useState } from "react"; const EmptyData = () => { return ( -
+

@@ -40,6 +40,11 @@ function Metrics(): JSX.Element { return ( <> +

+ My snaps / {snapId} / + Metrics +

+ {isEmpty && } diff --git a/static/sass/_snapcraft_metrics.scss b/static/sass/_snapcraft_metrics.scss index 6474636245..d5c7aa022f 100644 --- a/static/sass/_snapcraft_metrics.scss +++ b/static/sass/_snapcraft_metrics.scss @@ -61,4 +61,8 @@ vertical-align: middle; } } + + .snapcraft-metrics__info { + margin-top: 1.5rem; + } } From 175a239b291bc9b6d2492330992fa0bf61ecbec6 Mon Sep 17 00:00:00 2001 From: Steve Rydz Date: Tue, 8 Oct 2024 13:32:09 +0100 Subject: [PATCH 07/24] feat: Create endpoints to get repo data and builds data (#4873) --- webapp/publisher/snaps/build_views.py | 127 +++++++++----------------- webapp/publisher/snaps/views.py | 8 +- 2 files changed, 47 insertions(+), 88 deletions(-) diff --git a/webapp/publisher/snaps/build_views.py b/webapp/publisher/snaps/build_views.py index a66ae04327..d59018320e 100644 --- a/webapp/publisher/snaps/build_views.py +++ b/webapp/publisher/snaps/build_views.py @@ -14,7 +14,7 @@ from webapp.decorators import login_required from webapp.extensions import csrf from webapp.publisher.snaps.builds import map_build_and_upload_states -from werkzeug.exceptions import Unauthorized, Forbidden +from werkzeug.exceptions import Unauthorized GITHUB_SNAPCRAFT_USER_TOKEN = os.getenv("GITHUB_SNAPCRAFT_USER_TOKEN") GITHUB_WEBHOOK_HOST_URL = os.getenv("GITHUB_WEBHOOK_HOST_URL") @@ -66,7 +66,10 @@ def get_builds(lp_snap, selection): @login_required -def get_snap_builds(snap_name): +def get_snap_repo(snap_name): + res = {"message": "", "success": True} + data = {"github_orgs": [], "github_repository": None, "github_user": None} + details = publisher_api.get_snap_info(snap_name, flask.session) # API call to make users without needed permissions refresh the session @@ -75,16 +78,6 @@ def get_snap_builds(snap_name): session=flask.session, snap_name=snap_name, channels=["edge"] ) - context = { - "publisher_name": details["publisher"]["display-name"], - "snap_id": details["snap_id"], - "snap_name": details["snap_name"], - "snap_title": details["title"], - "snap_builds_enabled": False, - "snap_builds": [], - "total_builds": 0, - } - # Get built snap in launchpad with this store name lp_snap = launchpad.get_snap_by_store_name(details["snap_name"]) @@ -98,67 +91,52 @@ def get_snap_builds(snap_name): ) # Git repository without GitHub hostname - context["github_repository"] = lp_snap["git_repository_url"][19:] - github_owner, github_repo = context["github_repository"].split("/") - gh_snap_base = None + data["github_repository"] = lp_snap["git_repository_url"][19:] + github_owner, github_repo = data["github_repository"].split("/") - try: - context["github_repository_exists"] = github.check_if_repo_exists( - github_owner, github_repo - ) - context["yaml_file_exists"] = github.get_snapcraft_yaml_location( - github_owner, github_repo - ) + if not github.check_if_repo_exists(github_owner, github_repo): + data["success"] = False + data["message"] = "This app has been revoked" - if context["yaml_file_exists"]: - try: - yaml_data = github.get_snapcraft_yaml_data( - github_owner, - github_repo, - location=context["yaml_file_exists"], - ) - gh_snap_base = yaml_data.get( - "build-base", yaml_data.get("base", None) - ) - except InvalidYAML: - # If we can't parse the yaml we don't - # want to cause an error - pass - - except Unauthorized: - context["github_app_revoked"] = True - - builds = get_builds(lp_snap, slice(0, BUILDS_PER_PAGE)) - context.update(builds) - - # Notify about i386 arch - if gh_snap_base and ( - not gh_snap_base.startswith("core") - or ( - gh_snap_base.startswith("core") - and gh_snap_base.replace("core", "") - and int(gh_snap_base.replace("core", "")) >= 20 - ) - ): - # Check if this publisher was building for i386 recently - for build in builds["snap_builds"]: - if build["arch_tag"] == "i386": - context["dropped_i386"] = True - break + if github.get_user(): + data["github_user"] = github.get_user() + data["github_orgs"] = github.get_orgs() - context["snap_builds_enabled"] = bool(context["snap_builds"]) else: + data["github_repository"] = None github = GitHub(flask.session.get("github_auth_secret")) - try: - context["github_user"] = github.get_user() - except (Unauthorized, Forbidden): - context["github_user"] = None + if github.get_user(): + data["github_user"] = github.get_user() + data["github_orgs"] = github.get_orgs() + else: + data["success"] = False + data["message"] = "Unauthorized" - if context["github_user"]: - context["github_orgs"] = github.get_orgs() + res["data"] = data - return flask.render_template("publisher/builds.html", **context) + return flask.jsonify(res) + + +@login_required +def get_snap_builds(snap_name): + res = {"message": "", "success": True} + data = {"snap_builds": [], "total_builds": 0} + + details = publisher_api.get_snap_info(snap_name, flask.session) + start = flask.request.args.get("start", 0, type=int) + size = flask.request.args.get("size", 15, type=int) + build_slice = slice(start, size) + + # Get built snap in launchpad with this store name + lp_snap = launchpad.get_snap_by_store_name(details["snap_name"]) + + if lp_snap: + data.update(get_builds(lp_snap, build_slice)) + + res["data"] = data + + return flask.jsonify(res) @login_required @@ -247,25 +225,6 @@ def validate_repo(github_token, snap_name, gh_owner, gh_repo): return result -@login_required -def get_snap_builds_json(snap_name): - details = publisher_api.get_snap_info(snap_name, flask.session) - - context = {"snap_builds": []} - - start = flask.request.args.get("start", 0, type=int) - size = flask.request.args.get("size", 15, type=int) - build_slice = slice(start, size) - - # Get built snap in launchpad with this store name - lp_snap = launchpad.get_snap_by_store_name(details["snap_name"]) - - if lp_snap: - context.update(get_builds(lp_snap, build_slice)) - - return flask.jsonify(context) - - @login_required def get_validate_repo(snap_name): details = publisher_api.get_snap_info(snap_name, flask.session) diff --git a/webapp/publisher/snaps/views.py b/webapp/publisher/snaps/views.py index 40b978a9ac..8cf1ea2280 100644 --- a/webapp/publisher/snaps/views.py +++ b/webapp/publisher/snaps/views.py @@ -83,13 +83,13 @@ # Build views publisher_snaps.add_url_rule( - "//builds", - view_func=build_views.get_snap_builds, + "/api//repo", + view_func=build_views.get_snap_repo, methods=["GET"], ) publisher_snaps.add_url_rule( - "//builds.json", - view_func=build_views.get_snap_builds_json, + "/api//builds", + view_func=build_views.get_snap_builds, methods=["GET"], ) publisher_snaps.add_url_rule( From 46f36b5511f147d9a92d8588bda62d8f5f526a8c Mon Sep 17 00:00:00 2001 From: ilayda-cp Date: Mon, 21 Oct 2024 11:00:15 +0300 Subject: [PATCH 08/24] feat: update badge sources (#4892) --- .../pages/Publicise/PubliciseButtons.tsx | 15 +++++++++------ templates/store/snap-embedded-card.html | 5 ++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/static/js/publisher-pages/pages/Publicise/PubliciseButtons.tsx b/static/js/publisher-pages/pages/Publicise/PubliciseButtons.tsx index 8a9c205c9e..1f0a0aad67 100644 --- a/static/js/publisher-pages/pages/Publicise/PubliciseButtons.tsx +++ b/static/js/publisher-pages/pages/Publicise/PubliciseButtons.tsx @@ -23,20 +23,23 @@ function PubliciseButtons(): JSX.Element { const { snapId } = useParams(); const [selectedLanguage, setSelectedLanguage] = useState("en"); + const darkBadgeSource = `https://snapcraft.io/${selectedLanguage}/dark/install.svg`; + const lightBadgeSource = `https://snapcraft.io/${selectedLanguage}/light/install.svg`; + const htmlSnippetBlack = ` - Get it from the Snap Store + Get it from the Snap Store `; const htmlSnippetWhite = ` - Get it from the Snap Store + 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 markdownSnippetBlack = `[![Get it from the Snap Store](${darkBadgeSource})](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}) + const markdownSnippetWhite = `[![Get it from the Snap Store](${lightBadgeSource})](https://snapcraft.io/${snapId}) `; return ( @@ -73,7 +76,7 @@ function PubliciseButtons(): JSX.Element {

Get it from the Snap Store

Get it from the Snap Store{{ snap_title }}

{{ summary }}

{% endif %} {% if button %} -
- Get it from the Snap Store -
+ Get it from the Snap Store {% endif %}
From 4453b7c95cc6c1f396f0f019b25fc883b6867e49 Mon Sep 17 00:00:00 2001 From: Steve Rydz Date: Thu, 24 Oct 2024 09:07:57 +0100 Subject: [PATCH 09/24] feat: Add builds section to publisher pages (#4890) Simplify step 1 Simplify step 2 Simplify step 3 Simplify step 4 Simplify step 5 --- .../components/SectionNav/SectionNav.tsx | 3 +- static/js/publisher-pages/index.tsx | 28 +- .../js/publisher-pages/pages/Build/Build.tsx | 146 ++++++ .../js/publisher-pages/pages/Build/index.ts | 1 + .../publisher-pages/pages/Builds/Builds.tsx | 80 +++ .../pages/Builds/DisconnectRepoActions.tsx | 65 +++ .../pages/Builds/LoggedOut.tsx | 85 ++++ .../pages/Builds/RepoConnected.tsx | 279 ++++++++++ .../pages/Builds/RepoNotConnected.tsx | 81 +++ .../pages/Builds/RepoSelector.tsx | 266 ++++++++++ .../js/publisher-pages/pages/Builds/index.ts | 1 + static/js/publisher-pages/state/atoms.ts | 18 + static/js/publisher-pages/types/index.d.ts | 10 + .../builds/components/buildsTable.js | 145 ------ .../builds/components/datalistSelect.js | 49 -- .../builds/components/datalistSelect.test.js | 53 -- .../builds/components/repoConnect.js | 475 ------------------ .../builds/components/repoConnect.test.js | 38 -- .../js/publisher/builds/components/select.js | 29 -- .../builds/components/select.test.js | 33 -- .../builds/components/triggerBuild.js | 54 -- .../builds/components/triggerBuild.test.js | 29 -- static/js/publisher/builds/helpers.test.js | 50 -- static/js/publisher/builds/index.js | 314 ------------ static/js/publisher/publisher.ts | 8 - static/sass/_snapcraft_market.scss | 3 +- templates/publisher/build.html | 85 ---- templates/publisher/builds.html | 284 ----------- webapp/publisher/snaps/build_views.py | 14 +- webapp/publisher/snaps/views.py | 30 +- 30 files changed, 1094 insertions(+), 1662 deletions(-) create mode 100644 static/js/publisher-pages/pages/Build/Build.tsx create mode 100644 static/js/publisher-pages/pages/Build/index.ts create mode 100644 static/js/publisher-pages/pages/Builds/Builds.tsx create mode 100644 static/js/publisher-pages/pages/Builds/DisconnectRepoActions.tsx create mode 100644 static/js/publisher-pages/pages/Builds/LoggedOut.tsx create mode 100644 static/js/publisher-pages/pages/Builds/RepoConnected.tsx create mode 100644 static/js/publisher-pages/pages/Builds/RepoNotConnected.tsx create mode 100644 static/js/publisher-pages/pages/Builds/RepoSelector.tsx create mode 100644 static/js/publisher-pages/pages/Builds/index.ts create mode 100644 static/js/publisher-pages/state/atoms.ts delete mode 100644 static/js/publisher/builds/components/buildsTable.js delete mode 100644 static/js/publisher/builds/components/datalistSelect.js delete mode 100644 static/js/publisher/builds/components/datalistSelect.test.js delete mode 100644 static/js/publisher/builds/components/repoConnect.js delete mode 100644 static/js/publisher/builds/components/repoConnect.test.js delete mode 100644 static/js/publisher/builds/components/select.js delete mode 100644 static/js/publisher/builds/components/select.test.js delete mode 100644 static/js/publisher/builds/components/triggerBuild.js delete mode 100644 static/js/publisher/builds/components/triggerBuild.test.js delete mode 100644 static/js/publisher/builds/helpers.test.js delete mode 100644 static/js/publisher/builds/index.js delete mode 100644 templates/publisher/build.html delete mode 100644 templates/publisher/builds.html diff --git a/static/js/publisher-pages/components/SectionNav/SectionNav.tsx b/static/js/publisher-pages/components/SectionNav/SectionNav.tsx index 7fd2bacaa4..da7acf4e42 100644 --- a/static/js/publisher-pages/components/SectionNav/SectionNav.tsx +++ b/static/js/publisher-pages/components/SectionNav/SectionNav.tsx @@ -20,7 +20,8 @@ function SectionNav({ activeTab, snapName }: Props) { { label: "Builds", active: activeTab === "builds", - href: `/${snapName}/builds`, + to: `/${snapName}/builds`, + component: Link, }, { label: "Releases", diff --git a/static/js/publisher-pages/index.tsx b/static/js/publisher-pages/index.tsx index d7fa3d91ad..c29502a763 100644 --- a/static/js/publisher-pages/index.tsx +++ b/static/js/publisher-pages/index.tsx @@ -1,5 +1,6 @@ import { createRoot } from "react-dom/client"; import { createBrowserRouter, RouterProvider } from "react-router-dom"; +import { RecoilRoot } from "recoil"; import { QueryClient, QueryClientProvider } from "react-query"; import Root from "./routes/root"; @@ -9,6 +10,8 @@ import ValidationSets from "./pages/ValidationSets"; import ValidationSet from "./pages/ValidationSet"; import Metrics from "./pages/Metrics"; import Listing from "./pages/Listing"; +import Builds from "./pages/Builds"; +import Build from "./pages/Build"; const router = createBrowserRouter([ { @@ -47,6 +50,14 @@ const router = createBrowserRouter([ path: "/:snapId/listing", element: , }, + { + path: "/:snapId/builds", + element: , + }, + { + path: "/:snapId/builds/:buildId", + element: , + }, ], }, ]); @@ -54,10 +65,19 @@ const router = createBrowserRouter([ const rootEl = document.getElementById("root") as HTMLElement; const root = createRoot(rootEl); -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + }, +}); root.render( - - - , + + + + + , ); diff --git a/static/js/publisher-pages/pages/Build/Build.tsx b/static/js/publisher-pages/pages/Build/Build.tsx new file mode 100644 index 0000000000..d28763fa23 --- /dev/null +++ b/static/js/publisher-pages/pages/Build/Build.tsx @@ -0,0 +1,146 @@ +import { useParams, Link } from "react-router-dom"; +import { useQuery } from "react-query"; +import { Strip, Row, Col, MainTable } from "@canonical/react-components"; +import { formatDistanceToNow, formatDuration } from "date-fns"; + +import SectionNav from "../../components/SectionNav"; + +function Build(): JSX.Element { + const { buildId, snapId } = useParams(); + const { data, isFetched } = useQuery({ + queryKey: ["build"], + queryFn: async () => { + const response = await fetch(`/api/${snapId}/builds/${buildId}`); + + if (!response.ok) { + throw new Error("There was a problem trying to fetch build data"); + } + + const responseData = await response.json(); + + if (!responseData.success) { + throw new Error("There was a problem trying to fetch build data"); + } + + return responseData.data; + }, + }); + + const formatDurationString = (duration?: string): string => { + if (!duration) { + return "-"; + } + + const durationParts = duration.split(":"); + + return formatDuration({ + hours: parseInt(durationParts[0]), + minutes: parseInt(durationParts[1]), + seconds: Math.floor(parseInt(durationParts[2])), + }); + }; + + const formatStatus = (status: string): JSX.Element => { + switch (status) { + case "never_built": + return <>Never built; + case "building_soon": + return <>Building soon; + case "wont_release": + return <>Won't release; + case "released": + return <>Released; + case "release_failed": + return <>Release failed; + case "releasing_soon": + return <>Releasing soon; + case "in_progress": + return ( + <> + + In progress + + ); + case "failed_to_build": + return <>Failed to build; + case "cancelled": + return <>Cancelled; + case "unknown": + return <>Unknown; + case "ERROR": + return <>Error; + case "SUCCESS": + return <>Success; + case "IDLE": + return <>Idle; + default: + return <>{status}; + } + }; + + return ( + <> +

+ My snaps / {snapId} /{" "} + Builds / Build #{buildId} +

+ + + {isFetched && data && ( + + )} + + +

Build log

+ + + + Scroll to bottom + + {isFetched && data && ( + + View raw + + )} + +
+ {isFetched && data && ( + <> +
{data.raw_logs}
+ + )} +
+ + + ); +} + +export default Build; diff --git a/static/js/publisher-pages/pages/Build/index.ts b/static/js/publisher-pages/pages/Build/index.ts new file mode 100644 index 0000000000..8d02daadd7 --- /dev/null +++ b/static/js/publisher-pages/pages/Build/index.ts @@ -0,0 +1 @@ +export { default } from "./Build"; diff --git a/static/js/publisher-pages/pages/Builds/Builds.tsx b/static/js/publisher-pages/pages/Builds/Builds.tsx new file mode 100644 index 0000000000..54a7c1127e --- /dev/null +++ b/static/js/publisher-pages/pages/Builds/Builds.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; +import { useParams } from "react-router-dom"; +import { useQuery } from "react-query"; +import { useRecoilState } from "recoil"; +import { Strip } from "@canonical/react-components"; + +import SectionNav from "../../components/SectionNav"; +import LoggedOut from "./LoggedOut"; +import RepoNotConnected from "./RepoNotConnected"; +import RepoConnected from "./RepoConnected"; + +import { + buildLoggedInState, + buildRepoConnectedState, + githubDataState, +} from "../../state/atoms"; + +function Builds(): JSX.Element { + const { snapId } = useParams(); + const [githubData, setGithubData] = useRecoilState(githubDataState); + const [loggedIn, setLoggedIn] = useRecoilState(buildLoggedInState); + const [repoConnected, setRepoConnected] = useRecoilState( + buildRepoConnectedState, + ); + const [autoTriggerBuild, setAutoTriggerBuild] = useState(false); + const { isLoading } = useQuery({ + queryKey: ["githubData"], + queryFn: async () => { + const response = await fetch(`/api/${snapId}/repo`); + + if (!response.ok) { + setGithubData(null); + } + + const responseData = await response.json(); + + const githubData = responseData.data; + + setLoggedIn(githubData.github_user !== null); + setRepoConnected(githubData.github_repository !== null); + setGithubData(responseData.data); + + return responseData.data; + }, + retry: 0, + }); + + return ( + <> +

+ My snaps / {snapId} / + Builds +

+ + + + {isLoading && ( + +

+  Loading{" "} + {snapId} builds data +

+
+ )} + + {!isLoading && !loggedIn && } + {!isLoading && loggedIn && !repoConnected && ( + + )} + {githubData && loggedIn && repoConnected && ( + + )} + + ); +} + +export default Builds; diff --git a/static/js/publisher-pages/pages/Builds/DisconnectRepoActions.tsx b/static/js/publisher-pages/pages/Builds/DisconnectRepoActions.tsx new file mode 100644 index 0000000000..9d53ecafe9 --- /dev/null +++ b/static/js/publisher-pages/pages/Builds/DisconnectRepoActions.tsx @@ -0,0 +1,65 @@ +import { useState } from "react"; +import { useParams } from "react-router-dom"; +import { useSetRecoilState } from "recoil"; +import { Button } from "@canonical/react-components"; + +import { buildRepoConnectedState } from "../../state/atoms"; + +import type { GithubData } from "../../types"; + +type Props = { + setDisconnectModalOpen: Function; + githubData: GithubData | null; +}; + +function DisconnectRepoActions({ + setDisconnectModalOpen, + githubData, +}: Props): JSX.Element { + const { snapId } = useParams(); + const setRepoConnected = useSetRecoilState(buildRepoConnectedState); + const [disconnecting, setDisconnecting] = useState(false); + + const handleRepoDisconnect = async () => { + setDisconnecting(true); + const formData = new FormData(); + formData.set("csrf_token", window.CSRF_TOKEN); + const response = await fetch(`/api/${snapId}/builds/disconnect`, { + method: "POST", + body: formData, + }); + + if (!response.ok) { + if (githubData !== null) { + setRepoConnected(false); + } + } + + setRepoConnected(false); + setDisconnectModalOpen(false); + setDisconnecting(false); + }; + + return ( + <> + + + + ); +} + +export default DisconnectRepoActions; diff --git a/static/js/publisher-pages/pages/Builds/LoggedOut.tsx b/static/js/publisher-pages/pages/Builds/LoggedOut.tsx new file mode 100644 index 0000000000..ca91208d6a --- /dev/null +++ b/static/js/publisher-pages/pages/Builds/LoggedOut.tsx @@ -0,0 +1,85 @@ +import { useParams } from "react-router-dom"; +import { Strip, Row, Col } from "@canonical/react-components"; + +function BuildsDefault() { + const { snapId } = useParams(); + + return ( + <> +
+
+
    +
  • + Login to your GitHub account to start building. +
  • +
  • + + Log in + + +
  • +
+
+
+
+
+
+ +
+ +
+ + + +

+ Start by creating a repo and pushing your code to GitHub. Make + sure that your repo includes a snapcraft.yaml file. +

+ + + +

+ Register a name on Snapcraft and attach it to your repo to start + building. Your snap will be built automatically for all the + distros. +

+ + + +

+ Release your snap to your users. From here on, all the updates you + do to your code will trigger automatic builds. +

+ +
+
+ + ); +} + +export default BuildsDefault; diff --git a/static/js/publisher-pages/pages/Builds/RepoConnected.tsx b/static/js/publisher-pages/pages/Builds/RepoConnected.tsx new file mode 100644 index 0000000000..0e6511f451 --- /dev/null +++ b/static/js/publisher-pages/pages/Builds/RepoConnected.tsx @@ -0,0 +1,279 @@ +import { useState, useEffect } from "react"; +import { useQuery } from "react-query"; +import { useRecoilValue } from "recoil"; +import { useParams, Link } from "react-router-dom"; +import { formatDistanceToNow, formatDuration } from "date-fns"; +import { + Strip, + Button, + Modal, + Row, + Col, + MainTable, +} from "@canonical/react-components"; + +import DisconnectRepoActions from "./DisconnectRepoActions"; + +import { githubDataState } from "../../state/atoms"; + +function RepoConnected({ + autoTriggerBuild, + setAutoTriggerBuild, +}: { + autoTriggerBuild: boolean; + setAutoTriggerBuild: Function; +}): JSX.Element { + const { snapId } = useParams(); + const githubData = useRecoilValue(githubDataState); + const [disconnectModalOpen, setDisconnectModalOpen] = + useState(false); + const [triggeringBuild, setTriggeringBuild] = useState(false); + const { isLoading, isFetched, data, refetch } = useQuery({ + queryKey: ["repo"], + queryFn: async () => { + const response = await fetch(`/api/${snapId}/builds`); + const data = await response.json(); + return data.data; + }, + onSettled: () => { + setTriggeringBuild(false); + }, + }); + + const formatDurationString = (duration?: string): string => { + if (!duration) { + return "-"; + } + + const durationParts = duration.split(":"); + + return formatDuration({ + hours: parseInt(durationParts[0]), + minutes: parseInt(durationParts[1]), + seconds: Math.floor(parseInt(durationParts[2])), + }); + }; + + const formatStatus = (status: string): JSX.Element => { + switch (status) { + case "never_built": + return <>Never built; + case "building_soon": + return <>Building soon; + case "wont_release": + return <>Won't release; + case "released": + return <>Released; + case "release_failed": + return <>Release failed; + case "releasing_soon": + return <>Releasing soon; + case "in_progress": + return ( + <> + + In progress + + ); + case "failed_to_build": + return <>Failed to build; + case "cancelled": + return <>Cancelled; + case "unknown": + return <>Unknown; + case "ERROR": + return <>Error; + case "SUCCESS": + return <>Success; + case "IDLE": + return <>Idle; + default: + return <>{status}; + } + }; + + async function trigger() { + const response = await fetch(`/api/${snapId}/builds/trigger-build`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": window.CSRF_TOKEN, + }, + }); + + if (!response.ok) { + throw new Error("Unable to trigger new build"); + } + + refetch(); + setAutoTriggerBuild(false); + } + + if (autoTriggerBuild) { + trigger(); + } + + useEffect(() => { + setInterval(() => { + refetch(); + }, 30000); + }, []); + + return ( + <> + +
+
+ {githubData !== null && ( +

+ Your snap is linked to:{" "} + + {githubData.github_repository} + {" "} + |{" "} + +

+ )} +
+
+
+ + +

Latest builds

+ + + + +
+ {isLoading && <>Waiting for builds...} + {isFetched && data && ( + <> + { + return { + columns: [ + { + content: ( + + {build.id} + + ), + }, + { content: build.arch_tag }, + { content: formatDurationString(build.duration) }, + { + content: formatStatus(build.status), + className: "p-table__cell--icon-placeholder", + }, + { + content: build.datebuilt + ? formatDistanceToNow(build.datebuilt, { + addSuffix: true, + }) + : "-", + className: "u-align--right", + }, + ], + }; + }, + )} + /> + + )} + {disconnectModalOpen && ( + { + setDisconnectModalOpen(false); + }} + title="Disconnecting your repository" + buttonRow={ + + } + > +

+ By disconnecting this repository from your snap, the following will + occur: +

+
    +
  • + Previous builds for this snap will disappear from the Builds page +
  • +
  • + You will be able to continue to release your previous revisions on + the Releases tab +
  • +
+

+ Do you wish to{" "} + continue disconnecting your repository? +

+
+ )} + + ); +} + +export default RepoConnected; diff --git a/static/js/publisher-pages/pages/Builds/RepoNotConnected.tsx b/static/js/publisher-pages/pages/Builds/RepoNotConnected.tsx new file mode 100644 index 0000000000..fd04590593 --- /dev/null +++ b/static/js/publisher-pages/pages/Builds/RepoNotConnected.tsx @@ -0,0 +1,81 @@ +import { useRecoilValue } from "recoil"; +import { Strip } from "@canonical/react-components"; + +import RepoSelector from "./RepoSelector"; + +import { githubDataState } from "../../state/atoms"; + +import type { GithubData } from "../../types/"; + +function RepoNotConnected({ + setAutoTriggerBuild, +}: { + setAutoTriggerBuild: Function; +}): JSX.Element { + const githubData = useRecoilValue(githubDataState); + + return ( + <> + {githubData !== null && githubData.github_user && ( +
+
+ +
+
+ )} +
+
+
+ {githubData !== null && ( + + )} +
+
+
+ +
+

If you can't find a repository …

+

+ Want to use a private repo? We're working hard on + making these buildable. +

+

+ Don't have admin permission? Ask a repo admin to + add it instead, and it will show up in your repo list too. +

+

+ Missing an organization? +

+ + Review organization access + +
+
+ + ); +} + +export default RepoNotConnected; diff --git a/static/js/publisher-pages/pages/Builds/RepoSelector.tsx b/static/js/publisher-pages/pages/Builds/RepoSelector.tsx new file mode 100644 index 0000000000..767a914814 --- /dev/null +++ b/static/js/publisher-pages/pages/Builds/RepoSelector.tsx @@ -0,0 +1,266 @@ +import { ChangeEvent, ChangeEventHandler, useState } from "react"; +import { useParams } from "react-router-dom"; +import { useSetRecoilState } from "recoil"; +import { Strip, Row, Col, Select, Button } from "@canonical/react-components"; + +import { buildRepoConnectedState } from "../../state/atoms"; + +import type { GithubData } from "../../types/"; + +type Repo = { name: string; nameWithOwner: string }; + +type Props = { + githubData: GithubData; + setAutoTriggerBuild: Function; +}; + +function RepoSelector({ githubData, setAutoTriggerBuild }: Props) { + const rawLogsUrl = + "https://github.com/canonical/juju-dashboard-1/new/master?filename=snap%2Fsnapcraft.yaml&value=%0A%20%20%23%20After%20registering%20a%20name%20on%20build.snapcraft.io%2C%20commit%20an%20uncommented%20line%3A%0A%20%20%23%20name%3A%20steve-test-snap%0A%20%20version%3A%20%270.1%27%20%23%20just%20for%20humans%2C%20typically%20%271.2%2Bgit%27%20or%20%271.3.2%27%0A%20%20summary%3A%20Single-line%20elevator%20pitch%20for%20your%20amazing%20snap%20%23%2079%20char%20long%20summary%0A%20%20description%3A%20%7C%0A%20%20%20%20This%20is%20my-snap%27s%20description.%20You%20have%20a%20paragraph%20or%20two%20to%20tell%20the%0A%20%20%20%20most%20important%20story%20about%20your%20snap.%20Keep%20it%20under%20100%20words%20though%2C%0A%20%20%20%20we%20live%20in%20tweetspace%20and%20your%20description%20wants%20to%20look%20good%20in%20the%20snap%0A%20%20%20%20store.%0A%0A%20%20grade%3A%20devel%20%23%20must%20be%20%27stable%27%20to%20release%20into%20candidate%2Fstable%20channels%0A%20%20confinement%3A%20devmode%20%23%20use%20%27strict%27%20once%20you%20have%20the%20right%20plugs%20and%20slots%0A%0A%20%20parts%3A%0A%20%20%20%20my-part%3A%0A%20%20%20%20%20%20%23%20See%20%27snapcraft%20plugins%27%0A%20%20%20%20%20%20plugin%3A%20nil%0A%20%20"; + const { snapId } = useParams(); + const [selectedOrg, setSelectedOrg] = useState(null); + const [selectedRepo, setSelectedRepo] = useState(); + const [building, setBuilding] = useState(false); + const setRepoConnected = useSetRecoilState(buildRepoConnectedState); + const [validating, setValidating] = useState(false); + const [validationMessage, setValidationMessage] = useState(""); + const [repos, setRepos] = useState([]); + const [reposLoading, setReposLoading] = useState(false); + const [validRepo, setValidRepo] = useState(null); + const [validationError, setValidationError] = useState(false); + const validateRepo = async (repo: Repo | undefined) => { + if (!repo) { + return; + } + + setValidating(true); + + const response = await fetch( + `/api/${snapId}/builds/validate-repo?repo=${repo.nameWithOwner}`, + ); + + if (!response.ok) { + setValidationError(true); + throw new Error("Not a valid repo"); + } + + const responseData = await response.json(); + + if (responseData.success) { + setValidRepo(true); + setValidationMessage(""); + setValidationError(false); + } else { + setValidationMessage(responseData.error.message); + setValidationError(true); + setValidRepo(null); + } + + setValidating(false); + }; + + const getRepos = async (org?: string) => { + setReposLoading(true); + + let apiUrl = "/publisher/github/get-repos"; + + if (org) { + apiUrl += `?org=${org}`; + } + + const response = await fetch(apiUrl); + + if (!response.ok) { + throw Error("Unable to fetch repos"); + } + + const responseData = await response.json(); + + setReposLoading(false); + + setRepos(responseData); + }; + + const getOrgs = (): { label: string; value: string }[] => { + if (githubData) { + return githubData.github_orgs.map((org) => { + return { + label: org.name, + value: org.name, + }; + }); + } + + return []; + }; + + const getValidationStatusClassName = (): string => { + if (validRepo) { + return "is-success"; + } + + if (validationError) { + return "is-error"; + } + + return ""; + }; + + const handleOrganizationChange = (e: ChangeEvent) => { + const target = e.target as HTMLInputElement; + + if (target.value) { + setReposLoading(true); + + const org = target.value; + + if (org === githubData.github_user.login) { + getRepos(); + } else { + getRepos(org); + } + + setSelectedOrg(org); + } else { + setSelectedOrg(null); + } + }; + + const handleRepoChange = (e: ChangeEvent) => { + const target = e.target as HTMLInputElement; + const searchTerm = target.value; + + if (!searchTerm) { + setValidationError(false); + setValidRepo(false); + } + + const selectedRepo = repos.find((repo: Repo) => repo.name === searchTerm); + + setSelectedRepo(selectedRepo); + + if (selectedRepo) { + validateRepo(selectedRepo); + } + }; + + const connectRepo = async () => { + setBuilding(true); + const formData = new FormData(); + formData.set("csrf_token", window.CSRF_TOKEN); + if (selectedRepo) { + formData.set("github_repository", selectedRepo.nameWithOwner); + } + + const response = await fetch(`/api/${snapId}/builds`, { + method: "POST", + body: formData, + }); + + if (response) { + setAutoTriggerBuild(true); + } + + setRepoConnected(true); + }; + + return ( + +

+ Your repo needs a snapcraft.yaml file, so that Snapcraft can make it + buildable, installable, and runnable. +

+ + + + + {reposLoading || + (validating && ( + + + + ))} + + {repos.map((repo: Repo) => ( + + + + {validRepo === true && ( + + )} + {validationError && ( + + )} + + + {validationError && ( +
+

{validationMessage}

+

+ Learn the basics, or{" "} + get started with a template. +

+

+ Don't have snapcraft?{" "} + + Install it on your own PC for testing + + . +

+
+ )} +
+ ); +} + +export default RepoSelector; diff --git a/static/js/publisher-pages/pages/Builds/index.ts b/static/js/publisher-pages/pages/Builds/index.ts new file mode 100644 index 0000000000..ef7b783bd9 --- /dev/null +++ b/static/js/publisher-pages/pages/Builds/index.ts @@ -0,0 +1 @@ +export { default } from "./Builds"; diff --git a/static/js/publisher-pages/state/atoms.ts b/static/js/publisher-pages/state/atoms.ts new file mode 100644 index 0000000000..1c5d6f6d24 --- /dev/null +++ b/static/js/publisher-pages/state/atoms.ts @@ -0,0 +1,18 @@ +import { atom } from "recoil"; + +import type { GithubData } from "../types"; + +export const buildLoggedInState = atom({ + key: "buildLoggedInstate", + default: false, +}); + +export const buildRepoConnectedState = atom({ + key: "buildRepoConnectedState", + default: false, +}); + +export const githubDataState = atom({ + key: "githubDataState", + default: null, +}); diff --git a/static/js/publisher-pages/types/index.d.ts b/static/js/publisher-pages/types/index.d.ts index aa2e270670..0e0bc8828d 100644 --- a/static/js/publisher-pages/types/index.d.ts +++ b/static/js/publisher-pages/types/index.d.ts @@ -70,3 +70,13 @@ export type ListingData = { update_metadata_on_release: boolean; tour_steps: Step[]; }; + +export type GithubData = { + github_orgs: { name: string }[]; + github_repository: string | null; + github_user: { + avatarUrl: string; + login: string; + name: string; + }; +}; diff --git a/static/js/publisher/builds/components/buildsTable.js b/static/js/publisher/builds/components/buildsTable.js deleted file mode 100644 index 5dfcd7ccd4..0000000000 --- a/static/js/publisher/builds/components/buildsTable.js +++ /dev/null @@ -1,145 +0,0 @@ -import React, { Fragment } from "react"; -import PropTypes from "prop-types"; -import { formatDistance, parseISO } from "date-fns"; - -import ModularTable from "@canonical/react-components/dist/components/ModularTable"; -import SummaryButton from "@canonical/react-components/dist/components/SummaryButton"; - -import { UserFacingStatus, createDuration } from "../helpers"; - -const StatusCell = ({ build, queueTime }) => { - const status = UserFacingStatus[build.status]; - - const title = - build.queue_time && queueTime[build.arch_tag] - ? `Queue time: up to ${queueTime[build.arch_tag]}` - : null; - - return ( - - {status.shortStatusMessage} - - {status.statusMessage} - - - ); -}; - -StatusCell.propTypes = { - build: PropTypes.shape({ - status: PropTypes.string.isRequired, - queue_time: PropTypes.string, - arch_tag: PropTypes.string.isRequired, - }), - queueTime: PropTypes.object, -}; - -const BuildsTable = ({ - builds, - singleBuild, - snapName, - queueTime, - totalBuilds, - isLoading, - showMoreHandler, -}) => { - const columns = React.useMemo( - () => [ - { - Header: "ID", - accessor: "id", - Cell: ({ value }) => - value ? ( - singleBuild ? ( - `#${value}` - ) : ( - #{value} - ) - ) : ( - "" - ), - }, - { Header: "Architecture", accessor: "arch_tag" }, - { - Header: "Build Duration", - accessor: "duration", - className: "u-hide--small", - Cell: ({ value }) => createDuration(value), - }, - { - Header: "Result", - className: "p-table__cell--icon-placeholder u-truncate", - accessor: (build) => - build.status === "in_progress" && build.duration - ? "releasing_soon" - : build.status, - // this function is technically an inline React component, - // but we don't want to define a name and props for it - // eslint-disable-next-line react/display-name, react/prop-types - Cell: ({ row }) => ( - // get the raw build object from the row data - // eslint-disable-next-line react/prop-types - - ), - getCellIcon: ({ row }) => { - const status = UserFacingStatus[row.original.status]; - return status.icon ? status.icon : false; - }, - }, - { - Header: "Build Finished", - accessor: "datebuilt", - className: "u-align-text--right", - Cell: ({ value }) => - value - ? formatDistance(parseISO(value), new Date(), { - addSuffix: true, - }) - : "", - }, - ], - // updates to builds and queueTime affect columns, so they should be recalculated when they change - [builds, queueTime], - ); - - const data = React.useMemo(() => builds, [builds]); - - const remainingBuilds = totalBuilds - builds.length; - const showMoreCount = remainingBuilds > 15 ? 15 : remainingBuilds; - - const footer = - remainingBuilds > 0 ? ( -
- -
- ) : null; - - return ( - - - - ); -}; - -BuildsTable.propTypes = { - builds: PropTypes.array, - snapName: PropTypes.string, - singleBuild: PropTypes.bool, - queueTime: PropTypes.object, - totalBuilds: PropTypes.number, - isLoading: PropTypes.bool, - showMoreHandler: PropTypes.func, - value: PropTypes.string, -}; - -export default BuildsTable; diff --git a/static/js/publisher/builds/components/datalistSelect.js b/static/js/publisher/builds/components/datalistSelect.js deleted file mode 100644 index e28a4b7482..0000000000 --- a/static/js/publisher/builds/components/datalistSelect.js +++ /dev/null @@ -1,49 +0,0 @@ -import React, { Fragment } from "react"; -import PropTypes from "prop-types"; - -const DatalistSelect = ({ - placeholder, - options, - updateSelection, - disabled, - selectedOption, - listId, - isLoading, -}) => ( - - updateSelection(e.target.value)} - placeholder={placeholder} - disabled={disabled} - value={selectedOption} - className="p-form-validation__input" - /> - {isLoading ? ( - - - - ) : ( - "" - )} - - {options.map((item, i) => ( - - -); - -DatalistSelect.propTypes = { - options: PropTypes.array.isRequired, - selectedOption: PropTypes.string, - updateSelection: PropTypes.func, - placeholder: PropTypes.string, - disabled: PropTypes.bool, - isLoading: PropTypes.bool, - listId: PropTypes.string, - className: PropTypes.string, -}; - -export default DatalistSelect; diff --git a/static/js/publisher/builds/components/datalistSelect.test.js b/static/js/publisher/builds/components/datalistSelect.test.js deleted file mode 100644 index 03c61acc0e..0000000000 --- a/static/js/publisher/builds/components/datalistSelect.test.js +++ /dev/null @@ -1,53 +0,0 @@ -import React from "react"; -import { render, fireEvent } from "@testing-library/react"; - -import DatalistSelect from "./datalistSelect"; - -describe("DatalistSelect", () => { - const essentialProps = { - listId: "repo-list", - options: [{ value: "test-repo-1" }, { value: "test-repo-2" }], - updateSelection: jest.fn(), - selectedOption: "test-org-2", - }; - - it("should render all the essential components", () => { - const { container } = render(); - - expect(container.querySelector("input")).toBeDefined(); - expect(container.querySelector("datalist")).toBeDefined(); - }); - - it("should render input with 'test-org-2' value ", () => { - const { container } = render(); - const inputEl = container.querySelector("input"); - - expect(inputEl.value).toEqual("test-org-2"); - }); - - it("should render 2 datalist options", () => { - const { container } = render(); - const datalistEl = container.querySelector("datalist"); - - expect(datalistEl.childNodes.length).toEqual(2); - }); - - it("should call the callback function on change", () => { - const { container } = render(); - const inputEl = container.querySelector("input"); - - fireEvent.change(inputEl, { target: { value: "test-repo-1" } }); - expect(essentialProps.updateSelection).toHaveBeenCalledTimes(1); - }); - - it("should render the loading icon when loading", () => { - const { container } = render( - , - ); - const iconClassList = - container.querySelector(".p-icon-container").firstChild.classList; - - expect(iconClassList.contains("p-icon--spinner")).toBeTruthy(); - expect(iconClassList.contains("u-animation--spin")).toBeTruthy(); - }); -}); diff --git a/static/js/publisher/builds/components/repoConnect.js b/static/js/publisher/builds/components/repoConnect.js deleted file mode 100644 index 1087a56d03..0000000000 --- a/static/js/publisher/builds/components/repoConnect.js +++ /dev/null @@ -1,475 +0,0 @@ -import React, { Fragment } from "react"; -import { createRoot } from "react-dom/client"; -import PropTypes from "prop-types"; - -import Button from "@canonical/react-components/dist/components/Button"; -import DatalistSelect from "./datalistSelect"; -import Select from "./select"; - -const LOADING = "LOADING"; -const ERROR = "ERROR"; -const SNAP_NAME_DOES_NOT_MATCH = "SNAP_NAME_DOES_NOT_MATCH"; -const MISSING_YAML_FILE = "MISSING_YAML_FILE"; -const SUCCESS = "SUCCESS"; -const NO_REPOS = "NO_REPOS"; - -class RepoConnect extends React.Component { - constructor(props) { - super(props); - - this.state = { - selectedRepo: "", - repoList: [], - selectedOrganization: "", - organizations: this.props.organizations, - user: this.props.user, - isRepoListDisabled: true, - yamlSnap: null, - status: null, - snapName: this.props.snapName, - yamlFilePath: null, - error: null, - }; - - this.handleRepoSelect = this.handleRepoSelect.bind(this); - this.handleOrganizationSelect = this.handleOrganizationSelect.bind(this); - this.fetchRepoList = this.fetchRepoList.bind(this); - this.handleRefreshButtonClick = this.handleRefreshButtonClick.bind(this); - } - - sortByValue(a, b) { - return a.value.localeCompare(b.value); - } - - /** - * Create a string from orgname/repo or username/orgname/repo - * - * @param {string} selectedOrganization - * @param {string} selectedRepo - * - * @returns {string} - */ - static orgRepoString(selectedOrganization, selectedRepo) { - if (!selectedRepo) { - return selectedOrganization; - } - if (selectedRepo.indexOf("/") !== -1) { - return selectedRepo; - } - - return `${selectedOrganization}/${selectedRepo}`; - } - - /** - * Handle repository select event - * - * @param repository - */ - handleRepoSelect(selectedRepo) { - this.setState( - { - selectedRepo: selectedRepo, - status: null, - error: null, - }, - () => this.checkRepo(selectedRepo), - ); - } - - /** - * Handle organization select event - * - * @param organization - */ - handleOrganizationSelect(selectedOrganization) { - this.setState( - { selectedOrganization: selectedOrganization, selectedRepo: "" }, - () => this.fetchRepoList(), - ); - } - - /** - * Fetch repo list of the selected organization - * - */ - fetchRepoList() { - const { selectedOrganization, user } = this.state; - let url = ""; - - if (selectedOrganization === user.login) { - url = "/publisher/github/get-repos"; - } else { - url = `/publisher/github/get-repos?org=${selectedOrganization}`; - } - this.setState({ - isRepoListDisabled: true, - status: LOADING, - }); - fetch(url) - .then((res) => res.json()) - .then((result) => { - let newRepoList = result - .map((el) => { - // a user may have access to a repo in an org - // but they're not part of that org - // they may also have their own fork - // so we need to differentiate, this just shows - // the upstream org name in the repo list - if (el.nameWithOwner) { - if (el.nameWithOwner.indexOf(`${user.login}/`) === 0) { - return { - value: el.nameWithOwner.replace(`${user.login}/`, ""), - }; - } else { - return { value: el.nameWithOwner }; - } - } else { - return { value: el.name }; - } - }) - .sort(this.sortByValue); - this.setState({ - repoList: newRepoList, - isRepoListDisabled: false, - status: null, - }); - }) - .catch(() => { - this.setState({ - isRepoListDisabled: false, - status: ERROR, - error: { type: NO_REPOS }, - }); - }); - } - - /** - * Check if repository is suitable for build - * - * @param selectedRepo - */ - checkRepo(selectedRepo) { - const { snapName, repoList, selectedOrganization } = this.state; - if (selectedRepo && repoList.some((el) => el.value === selectedRepo)) { - const orgRepo = RepoConnect.orgRepoString( - selectedOrganization, - selectedRepo, - ); - const url = `/${snapName}/builds/validate-repo?repo=${orgRepo}`; - - this.setState({ - isRepoListDisabled: true, - status: LOADING, - }); - - fetch(url) - .then((res) => res.json()) - .then((result) => { - if (result.success) { - this.setState({ - isRepoListDisabled: false, - status: SUCCESS, - }); - } else { - this.setState({ - isRepoListDisabled: false, - error: result.error, - status: ERROR, - yamlSnap: result.error.gh_snap_name - ? result.error.gh_snap_name - : null, - yamlFilePath: result.error.yaml_location - ? result.error.yaml_location - : null, - }); - } - }) - .catch(() => { - this.setState({ - isRepoListDisabled: false, - status: ERROR, - }); - }); - } - } - - // Handle Refresh button click - handleRefreshButtonClick(e) { - e.preventDefault(); - this.checkRepo(this.state.selectedRepo); - } - - renderNameError() { - const { - yamlSnap, - snapName, - selectedOrganization, - selectedRepo, - yamlFilePath, - } = this.state; - - const orgRepo = RepoConnect.orgRepoString( - selectedOrganization, - selectedRepo, - ); - - return ( -
-

- Name mismatch: - {`the snapcraft.yaml uses the snap name "${yamlSnap}", but you've registered the name "${snapName}". `} - - Update your snapcraft.yaml to continue. - -

-
- ); - } - - renderMissingYamlError() { - return ( -
-

- Missing snapcraft.yaml: - this repo needs a snapcraft.yaml file, so that Snapcraft can make it - buildable, installable and runnable. -

-

- - Learn the basics - - , or get started with a template. -

-

- Don’t have snapcraft?{" "} - - Install it on your own PC for testing. - -

-
- ); - } - - renderNoReposError() { - const { selectedOrganization } = this.state; - return ( -
-

- Can’t list repos: - We were not able to find or access any repos in {selectedOrganization} - . If you are an admin of the organization please check the{" "} - - application access policy - {" "} - for Snapcraft . -

-
- ); - } - - renderError() { - const { snapName, error } = this.state; - - if (error.message) { - return ( -
-

- Error: - {error.message} -

-
- ); - } else { - return ( -
-

- Error: - We were not able to check if your repository can be linked to{" "} - {snapName}. Please check your internet connection and{" "} - try again. -

-
- ); - } - } - - renderErrorMessage() { - const { error } = this.state; - if (!error) { - return this.renderError(); - } - switch (error.type) { - case SNAP_NAME_DOES_NOT_MATCH: - return this.renderNameError(); - case MISSING_YAML_FILE: - return this.renderMissingYamlError(); - case NO_REPOS: - return this.renderNoReposError(); - default: - return this.renderError(); - } - } - - getTemplateUrl() { - const { selectedOrganization, selectedRepo, snapName } = this.state; - const orgRepo = RepoConnect.orgRepoString( - selectedOrganization, - selectedRepo, - ); - return `https://github.com/${orgRepo}/new/master?filename=snap%2Fsnapcraft.yaml&value=%0A%20%20%23%20After%20registering%20a%20name%20on%20build.snapcraft.io%2C%20commit%20an%20uncommented%20line%3A%0A%20%20%23%20name%3A%20${snapName}%0A%20%20version%3A%20%270.1%27%20%23%20just%20for%20humans%2C%20typically%20%271.2%2Bgit%27%20or%20%271.3.2%27%0A%20%20summary%3A%20Single-line%20elevator%20pitch%20for%20your%20amazing%20snap%20%23%2079%20char%20long%20summary%0A%20%20description%3A%20%7C%0A%20%20%20%20This%20is%20my-snap%27s%20description.%20You%20have%20a%20paragraph%20or%20two%20to%20tell%20the%0A%20%20%20%20most%20important%20story%20about%20your%20snap.%20Keep%20it%20under%20100%20words%20though%2C%0A%20%20%20%20we%20live%20in%20tweetspace%20and%20your%20description%20wants%20to%20look%20good%20in%20the%20snap%0A%20%20%20%20store.%0A%0A%20%20grade%3A%20devel%20%23%20must%20be%20%27stable%27%20to%20release%20into%20candidate%2Fstable%20channels%0A%20%20confinement%3A%20devmode%20%23%20use%20%27strict%27%20once%20you%20have%20the%20right%20plugs%20and%20slots%0A%0A%20%20parts%3A%0A%20%20%20%20my-part%3A%0A%20%20%20%20%20%20%23%20See%20%27snapcraft%20plugins%27%0A%20%20%20%20%20%20plugin%3A%20nil%0A%20%20`; - } - - renderButton() { - const { status } = this.state; - if (status === ERROR) { - return ( - - ); - } else if (status === SUCCESS) { - return ; - } - } - - renderIcon() { - const { status } = this.state; - let icon; - switch (status) { - default: - icon = ""; - break; - case ERROR: - icon = " is-error"; - break; - case SUCCESS: - icon = " is-success"; - break; - } - return icon; - } - - componentDidMount() { - // Pre-select user's own "organization" if the user is not part of any organizations - array length 2 - // [ { value: "Select organization", disabled: true }, { value: user.login }, { value: organization1 }, { value: organization2 }, ... ] - if (this.state.organizations.length === 2) { - this.setState({ selectedOrganization: this.state.user.login }, () => { - this.fetchRepoList(); - }); - } - } - - render() { - const { - organizations, - selectedOrganization, - selectedRepo, - isRepoListDisabled, - repoList, - status, - } = this.state; - - const orgRepo = RepoConnect.orgRepoString( - selectedOrganization, - selectedRepo, - ); - - return ( - -
-
- - updateSelection(e.target.value)} - disabled={disabled} - value={selectedOption} - > - {options.map((item, i) => ( - - ))} - -); - -Select.propTypes = { - options: PropTypes.array.isRequired, - selectedOption: PropTypes.string, - updateSelection: PropTypes.func, - disabled: PropTypes.bool, -}; - -export default Select; diff --git a/static/js/publisher/builds/components/select.test.js b/static/js/publisher/builds/components/select.test.js deleted file mode 100644 index cc8557b654..0000000000 --- a/static/js/publisher/builds/components/select.test.js +++ /dev/null @@ -1,33 +0,0 @@ -import React from "react"; -import { render, fireEvent } from "@testing-library/react"; - -import Select from "./select"; - -describe("Select", () => { - const essentialProps = { - options: [{ value: "test-1" }, { value: "test-2" }], - updateSelection: jest.fn(), - selectedOption: "test-1", - }; - - it("should render all the essential components", () => { - const { container } = render(); - const selectEl = container.querySelector("select"); - - expect(selectEl.childNodes.length).toEqual(2); - }); - - it("should call the callback function on change", () => { - const { container } = render( - - {% if github_repository %} -
-
-

- Your snap is linked to:
- - {{ github_repository }} -  |  - Disconnect repo -

-
- {% if dropped_i386 %} -
-
-
-

We have detected that your snap base is core20 or newer. This is not compatible with the i386 architecture, therefore that architecture will no longer be built.
Learn more

-
-
-
- {% endif %} -
- {% else %} -
-
-
    -
  • - {% if not github_user %} - Login to your GitHub account to start building. - {% else %} - Your GitHub account is connected. - {% endif %} -
  • -
  • - {% if github_user %} - - @{{ github_user['login'] }} - {{ github_user["login"] }} - - {% else %} - - Log in - - {% endif %} -
  • -
-
-
-
-
-
- {% endif %} - - {% if github_repository %} - {% if github_app_revoked or not github_repository_exists or not yaml_file_exists %} -
-
-
-
-

- {% if github_app_revoked %} - Access was revoked to your GitHub account. Please check your GitHub OAuth apps or click here. - {% elif not github_repository_exists %} - Your snap is linked to {{ github_repository }}, but this repository doesn’t exist. Disconnect repo. - {% elif not yaml_file_exists %} - This repository doesn’t contain snapcraft.yaml. More on how to create it. - {% endif %} -

-
-
-
-
- {% endif %} - {% endif %} - - - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
-
- {% for category, message in messages %} -
-
-

{{ message }}

-
-
- {% endfor %} -
-
- {% endif %} - {% endwith %} - - - {% if not github_repository %} -
- {% if not github_user %} -
- -
-
-
- -

- Start by creating a repo and pushing your code to GitHub. Make - sure that your repo includes a snapcraft.yaml file. -

-
-
- -

- Register a name on Snapcraft and attach it to your repo to start - building. Your snap will be built automatically for all the - distros. -

-
-
- -

- Release your snap to your users. From here on, all the updates you - do to your code will trigger automatic builds. -

-
-
- {% else %} -
-

Your repo needs a snapcraft.yaml file, so that Snapcraft can make it - buildable, - installable, and runnable.

- -
- -
- {% endif %} -
- {% endif %} - - {% if not github_repository and github_user %} -
-
-
-
-
-
-
-

If you can't find a repository …

-

Want to use a private repo? We're working hard on - making - these buildable.

-
-
-

Don't have admin permission? Ask a repo admin to - add it instead, and it will show up in your repo list too. - {{github_repositories}}

-

Missing an organization? - {% if github_orgs|length == 0%} - Snapcraft doesn't have access to any organizations. - {% else %} - Snapcraft has access to - {{ github_orgs|length }} organizations you're a member of - {% if github_orgs|length == 1 %} - {{ github_orgs[0].name }}) - {% else %} - {% for github_org in github_orgs %} - {% if loop.index < github_orgs|length-1 %} - {{ github_org.name }}, - {% elif loop.index < github_orgs|length %} - {{ github_org.name }} - {% else %} - and {{ github_org.name }}. - {% endif %} - {% endfor %} - {% endif %} - {% endif %} -

- Review organization access -
-
- {% endif %} - - - {% if github_repository %} -
-
- {% include "publisher/_repo-disconnect-modal.html" %} -
-
-
- - - - - - - - - - - - - - - - - - -
IDArchitectureDurationResult
Waiting for builds...
-
-
-
- {% endif %} -
- -{% endblock %} - -{% block scripts_includes %} - -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/webapp/publisher/snaps/build_views.py b/webapp/publisher/snaps/build_views.py index d59018320e..a2693a96cd 100644 --- a/webapp/publisher/snaps/build_views.py +++ b/webapp/publisher/snaps/build_views.py @@ -118,6 +118,18 @@ def get_snap_repo(snap_name): return flask.jsonify(res) +@login_required +def get_snap_builds_page(snap_name): + return flask.render_template("store/publisher.html", snap_name=snap_name) + + +@login_required +def get_snap_build_page(snap_name, build_id): + return flask.render_template( + "store/publisher.html", snap_name=snap_name, build_id=build_id + ) + + @login_required def get_snap_builds(snap_name): res = {"message": "", "success": True} @@ -173,7 +185,7 @@ def get_snap_build(snap_name, build_id): details["snap_name"], build_id ) - return flask.render_template("publisher/build.html", **context) + return flask.jsonify({"data": context, "success": True}) def validate_repo(github_token, snap_name, gh_owner, gh_repo): diff --git a/webapp/publisher/snaps/views.py b/webapp/publisher/snaps/views.py index 8cf1ea2280..f0234ecdab 100644 --- a/webapp/publisher/snaps/views.py +++ b/webapp/publisher/snaps/views.py @@ -82,6 +82,18 @@ ) # Build views +publisher_snaps.add_url_rule( + "//builds", + view_func=build_views.get_snap_builds_page, + methods=["GET"], +), + +publisher_snaps.add_url_rule( + "//builds/", + view_func=build_views.get_snap_build_page, + methods=["GET"], +), + publisher_snaps.add_url_rule( "/api//repo", view_func=build_views.get_snap_repo, @@ -93,48 +105,48 @@ methods=["GET"], ) publisher_snaps.add_url_rule( - "//builds", + "/api//builds", view_func=build_views.post_snap_builds, methods=["POST"], ) publisher_snaps.add_url_rule( - "//builds/", + "/api//builds/", view_func=build_views.get_snap_build, methods=["GET"], ) publisher_snaps.add_url_rule( - "//builds/validate-repo", + "/api//builds/validate-repo", view_func=build_views.get_validate_repo, methods=["GET"], ) publisher_snaps.add_url_rule( - "//builds/trigger-build", + "/api//builds/trigger-build", view_func=build_views.post_build, methods=["POST"], ) publisher_snaps.add_url_rule( - "//builds/check-build-request/", + "/api//builds/check-build-request/", view_func=build_views.check_build_request, methods=["GET"], ) publisher_snaps.add_url_rule( - "//webhook/notify", + "/api//webhook/notify", view_func=build_views.post_github_webhook, methods=["POST"], ) # This route is to support previous webhooks from build.snapcraft.io publisher_snaps.add_url_rule( - "///webhook/notify", + "/api///webhook/notify", view_func=build_views.post_github_webhook, methods=["POST"], ) publisher_snaps.add_url_rule( - "//builds/update-webhook", + "/api//builds/update-webhook", view_func=build_views.get_update_gh_webhooks, methods=["GET"], ) publisher_snaps.add_url_rule( - "//builds/disconnect/", + "/api//builds/disconnect/", view_func=build_views.post_disconnect_repo, methods=["POST"], ) From a7f35e741da515a0ddb5379603dea2a2a3f3e7c0 Mon Sep 17 00:00:00 2001 From: Steve Rydz Date: Mon, 18 Nov 2024 13:09:52 +0000 Subject: [PATCH 10/24] feat: Move releases section into the publisher app (#4902) --- static/js/publisher-pages/index.tsx | 5 + .../pages/Releases/Release.tsx | 64 ++ .../pages/Releases/Releases.tsx | 66 ++ .../pages/Releases}/actions/architectures.js | 0 .../Releases}/actions/architectures.test.js | 0 .../actions/availableRevisionsSelect.js | 0 .../actions/availableRevisionsSelect.test.js | 0 .../pages/Releases}/actions/branches.js | 0 .../pages/Releases}/actions/branches.test.js | 0 .../pages/Releases}/actions/channelMap.js | 0 .../Releases}/actions/channelMap.test.js | 0 .../pages/Releases}/actions/currentTrack.js | 0 .../Releases}/actions/currentTrack.test.js | 0 .../pages/Releases}/actions/defaultTrack.js | 0 .../Releases}/actions/defaultTrack.test.js | 0 .../Releases}/actions/gaEventTracking.js | 2 +- .../Releases}/actions/gaEventTracking.test.js | 0 .../Releases}/actions/globalNotification.js | 0 .../actions/globalNotification.test.js | 0 .../pages/Releases}/actions/history.js | 0 .../pages/Releases}/actions/history.test.js | 0 .../pages/Releases}/actions/index.js | 0 .../pages/Releases}/actions/modal.js | 0 .../pages/Releases}/actions/modal.test.js | 0 .../pages/Releases}/actions/pendingCloses.js | 0 .../Releases}/actions/pendingCloses.test.js | 0 .../Releases}/actions/pendingReleases.js | 0 .../Releases}/actions/pendingReleases.test.js | 0 .../pages/Releases}/actions/releases.test.js | 0 .../pages/Releases}/actions/releases.ts | 0 .../pages/Releases}/actions/revisions.js | 0 .../pages/Releases}/actions/revisions.test.js | 0 .../pages/Releases}/api/releases.js | 0 .../ProgressiveReleaseProgressChart.tsx | 0 .../Releases}/components/TrackInfo.test.tsx | 0 .../pages/Releases}/components/TrackInfo.tsx | 0 .../pages/Releases}/components/channelMenu.js | 0 .../Releases}/components/contextualMenu.js | 0 .../components/defaultTrackModifier.js | 0 .../pages/Releases}/components/dnd.tsx | 0 .../components/globalNotification.js | 0 .../pages/Releases}/components/historyIcon.js | 0 .../Releases}/components/historyPanel.js | 0 .../pages/Releases}/components/modal.js | 0 .../Releases}/components/notification.js | 0 .../Releases}/components/progressiveBar.js | 0 .../components/progressiveConfirm.js | 0 .../Releases}/components/releasesConfirm.tsx | 2 +- .../components/releasesConfirmActions.js | 0 .../cancelProgressiveRow.js | 0 .../closeChannelsRow.js | 0 .../releasesConfirmDetails/globalRow.js | 0 .../releasesConfirmDetails/index.js | 0 .../releasesConfirmDetails/progressiveRow.js | 0 .../progressiveRowGroup.js | 0 .../releasesConfirmDetails/releaseRow.js | 0 .../releasesConfirmDetails/types.js | 0 .../Releases/components/releasesHeading.js | 593 +++++++++++++++++ .../components/releasesTable/cellViews.js | 0 .../releasesTable/channelHeading.tsx | 2 +- .../components/releasesTable/channelRow.js | 0 .../components/releasesTable/droppableRow.tsx | 0 .../components/releasesTable/index.js | 0 .../components/releasesTable/releaseCell.js | 0 .../releasesTable/releaseMenuItem.js | 0 .../components/releasesTable/revisionCell.js | 0 .../components/releasesTable/revisionsRow.js | 0 .../components/releasesTable/row.tsx | 0 .../Releases}/components/revisionLabel.js | 0 .../Releases}/components/revisionsList.js | 0 .../Releases}/components/revisionsListRow.js | 6 +- .../pages/Releases}/constants.js | 0 .../pages/Releases}/helpers.test.js | 0 .../pages/Releases}/helpers.ts | 2 +- .../publisher-pages/pages/Releases/index.ts | 1 + .../pages/Releases}/reducers/architectures.js | 0 .../Releases}/reducers/architectures.test.js | 0 .../reducers/availableRevisionsSelect.js | 0 .../reducers/availableRevisionsSelect.test.js | 0 .../pages/Releases}/reducers/branches.js | 0 .../pages/Releases}/reducers/branches.test.js | 0 .../pages/Releases}/reducers/channelMap.js | 0 .../Releases}/reducers/channelMap.test.js | 0 .../pages/Releases}/reducers/currentTrack.js | 0 .../Releases}/reducers/currentTrack.test.js | 0 .../pages/Releases}/reducers/defaultTrack.js | 0 .../Releases}/reducers/defaultTrack.test.js | 0 .../Releases}/reducers/globalNotification.js | 0 .../reducers/globalNotification.test.js | 0 .../pages/Releases}/reducers/history.js | 0 .../pages/Releases}/reducers/history.test.js | 0 .../pages/Releases}/reducers/index.js | 0 .../pages/Releases}/reducers/modal.js | 0 .../pages/Releases}/reducers/modal.test.js | 0 .../pages/Releases}/reducers/options.js | 0 .../pages/Releases}/reducers/pendingCloses.js | 0 .../Releases}/reducers/pendingCloses.test.js | 0 .../reducers/pendingReleases.test.js | 0 .../Releases}/reducers/pendingReleases.ts | 0 .../pages/Releases}/reducers/releases.js | 0 .../pages/Releases}/reducers/releases.test.js | 0 .../pages/Releases}/reducers/revisions.js | 0 .../Releases}/reducers/revisions.test.js | 0 .../pages/Releases}/releasesController.js | 0 .../pages/Releases}/releasesState.js | 0 .../pages/Releases}/selectors/index.ts | 2 +- .../Releases}/selectors/selectors.test.js | 0 .../publisher-pages/types/releaseTypes.d.ts | 92 +++ .../release/components/releasesHeading.js | 595 ------------------ templates/publisher/release-history.html | 57 -- tests/publisher/snaps/tests_revision.py | 87 --- webapp/publisher/snaps/release_views.py | 15 +- webapp/publisher/snaps/views.py | 7 +- webpack.config.entry.js | 1 - webpack.config.rules.js | 4 - 115 files changed, 847 insertions(+), 756 deletions(-) create mode 100644 static/js/publisher-pages/pages/Releases/Release.tsx create mode 100644 static/js/publisher-pages/pages/Releases/Releases.tsx rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/architectures.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/architectures.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/availableRevisionsSelect.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/availableRevisionsSelect.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/branches.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/branches.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/channelMap.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/channelMap.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/currentTrack.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/currentTrack.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/defaultTrack.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/defaultTrack.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/gaEventTracking.js (92%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/gaEventTracking.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/globalNotification.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/globalNotification.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/history.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/history.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/index.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/modal.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/modal.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/pendingCloses.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/pendingCloses.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/pendingReleases.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/pendingReleases.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/releases.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/releases.ts (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/revisions.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/actions/revisions.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/api/releases.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/ProgressiveReleaseProgressChart.tsx (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/TrackInfo.test.tsx (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/TrackInfo.tsx (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/channelMenu.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/contextualMenu.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/defaultTrackModifier.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/dnd.tsx (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/globalNotification.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/historyIcon.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/historyPanel.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/modal.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/notification.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/progressiveBar.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/progressiveConfirm.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/releasesConfirm.tsx (99%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/releasesConfirmActions.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/releasesConfirmDetails/cancelProgressiveRow.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/releasesConfirmDetails/closeChannelsRow.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/releasesConfirmDetails/globalRow.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/releasesConfirmDetails/index.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/releasesConfirmDetails/progressiveRow.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/releasesConfirmDetails/progressiveRowGroup.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/releasesConfirmDetails/releaseRow.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/releasesConfirmDetails/types.js (100%) create mode 100644 static/js/publisher-pages/pages/Releases/components/releasesHeading.js rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/releasesTable/cellViews.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/releasesTable/channelHeading.tsx (99%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/releasesTable/channelRow.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/releasesTable/droppableRow.tsx (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/releasesTable/index.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/releasesTable/releaseCell.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/releasesTable/releaseMenuItem.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/releasesTable/revisionCell.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/releasesTable/revisionsRow.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/releasesTable/row.tsx (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/revisionLabel.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/revisionsList.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/components/revisionsListRow.js (98%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/constants.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/helpers.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/helpers.ts (98%) create mode 100644 static/js/publisher-pages/pages/Releases/index.ts rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/architectures.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/architectures.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/availableRevisionsSelect.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/availableRevisionsSelect.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/branches.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/branches.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/channelMap.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/channelMap.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/currentTrack.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/currentTrack.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/defaultTrack.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/defaultTrack.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/globalNotification.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/globalNotification.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/history.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/history.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/index.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/modal.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/modal.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/options.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/pendingCloses.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/pendingCloses.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/pendingReleases.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/pendingReleases.ts (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/releases.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/releases.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/revisions.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/reducers/revisions.test.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/releasesController.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/releasesState.js (100%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/selectors/index.ts (99%) rename static/js/{publisher/release => publisher-pages/pages/Releases}/selectors/selectors.test.js (100%) create mode 100644 static/js/publisher-pages/types/releaseTypes.d.ts delete mode 100644 static/js/publisher/release/components/releasesHeading.js delete mode 100644 templates/publisher/release-history.html delete mode 100644 tests/publisher/snaps/tests_revision.py diff --git a/static/js/publisher-pages/index.tsx b/static/js/publisher-pages/index.tsx index c29502a763..18e9e2e5d8 100644 --- a/static/js/publisher-pages/index.tsx +++ b/static/js/publisher-pages/index.tsx @@ -12,6 +12,7 @@ import Metrics from "./pages/Metrics"; import Listing from "./pages/Listing"; import Builds from "./pages/Builds"; import Build from "./pages/Build"; +import Releases from "./pages/Releases"; const router = createBrowserRouter([ { @@ -58,6 +59,10 @@ const router = createBrowserRouter([ path: "/:snapId/builds/:buildId", element: , }, + { + path: "/:snapId/releases", + element: , + }, ], }, ]); diff --git a/static/js/publisher-pages/pages/Releases/Release.tsx b/static/js/publisher-pages/pages/Releases/Release.tsx new file mode 100644 index 0000000000..9d7a61b3d8 --- /dev/null +++ b/static/js/publisher-pages/pages/Releases/Release.tsx @@ -0,0 +1,64 @@ +import { createStore, applyMiddleware, compose } from "redux"; +import thunk from "redux-thunk"; +import { HTML5Backend } from "react-dnd-html5-backend"; +import { Provider } from "react-redux"; +import { DndProvider } from "react-dnd"; +import ReleasesController from "./releasesController"; +import releases from "./reducers"; +import { + ReleasesData, + ChannelMap, + Track, + Options, +} from "../../types/releaseTypes"; + +// setup redux store with thunk middleware and devtools extension: +// https://github.com/zalmoxisus/redux-devtools-extension#12-advanced-store-setup +const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; + +type Props = { + snapName: string; + releasesData: ReleasesData; + channelMap: ChannelMap[]; + tracks: Track[]; + options: Options; +}; + +function Release({ + snapName, + releasesData, + channelMap, + tracks, + options, +}: Props): JSX.Element { + const store = createStore( + releases, + { + currentTrack: options.defaultTrack || "latest", + defaultTrack: options.defaultTrack, + options: { + ...options, + // @ts-ignore + snapName, + tracks, + }, + }, + composeEnhancers(applyMiddleware(thunk)), + ); + + return ( + + + + + + ); +} + +export default Release; diff --git a/static/js/publisher-pages/pages/Releases/Releases.tsx b/static/js/publisher-pages/pages/Releases/Releases.tsx new file mode 100644 index 0000000000..6ededc2558 --- /dev/null +++ b/static/js/publisher-pages/pages/Releases/Releases.tsx @@ -0,0 +1,66 @@ +import { useParams } from "react-router-dom"; +import { useQuery } from "react-query"; +import { Strip, Link } from "@canonical/react-components"; + +import SectionNav from "../../components/SectionNav"; +import Release from "./Release"; + +function Releases(): JSX.Element { + const { snapId } = useParams(); + const { isLoading, isFetched, data } = useQuery({ + queryKey: ["releases"], + queryFn: async () => { + const response = await fetch(`/api/${snapId}/releases`); + + if (!response.ok) { + throw new Error("There was a problem fetching releases data"); + } + + const responseData = await response.json(); + + if (!responseData.success) { + throw new Error("There was a problem fetching releases data"); + } + + return responseData.data; + }, + }); + + return ( + <> +

+ My snaps /{" "} + {snapId} / Releases +

+ + + + {isLoading && ( + +

+  Loading{" "} + {snapId} releases data +

+
+ )} + + {isFetched && data && ( + + )} + + ); +} + +export default Releases; diff --git a/static/js/publisher/release/actions/architectures.js b/static/js/publisher-pages/pages/Releases/actions/architectures.js similarity index 100% rename from static/js/publisher/release/actions/architectures.js rename to static/js/publisher-pages/pages/Releases/actions/architectures.js diff --git a/static/js/publisher/release/actions/architectures.test.js b/static/js/publisher-pages/pages/Releases/actions/architectures.test.js similarity index 100% rename from static/js/publisher/release/actions/architectures.test.js rename to static/js/publisher-pages/pages/Releases/actions/architectures.test.js diff --git a/static/js/publisher/release/actions/availableRevisionsSelect.js b/static/js/publisher-pages/pages/Releases/actions/availableRevisionsSelect.js similarity index 100% rename from static/js/publisher/release/actions/availableRevisionsSelect.js rename to static/js/publisher-pages/pages/Releases/actions/availableRevisionsSelect.js diff --git a/static/js/publisher/release/actions/availableRevisionsSelect.test.js b/static/js/publisher-pages/pages/Releases/actions/availableRevisionsSelect.test.js similarity index 100% rename from static/js/publisher/release/actions/availableRevisionsSelect.test.js rename to static/js/publisher-pages/pages/Releases/actions/availableRevisionsSelect.test.js diff --git a/static/js/publisher/release/actions/branches.js b/static/js/publisher-pages/pages/Releases/actions/branches.js similarity index 100% rename from static/js/publisher/release/actions/branches.js rename to static/js/publisher-pages/pages/Releases/actions/branches.js diff --git a/static/js/publisher/release/actions/branches.test.js b/static/js/publisher-pages/pages/Releases/actions/branches.test.js similarity index 100% rename from static/js/publisher/release/actions/branches.test.js rename to static/js/publisher-pages/pages/Releases/actions/branches.test.js diff --git a/static/js/publisher/release/actions/channelMap.js b/static/js/publisher-pages/pages/Releases/actions/channelMap.js similarity index 100% rename from static/js/publisher/release/actions/channelMap.js rename to static/js/publisher-pages/pages/Releases/actions/channelMap.js diff --git a/static/js/publisher/release/actions/channelMap.test.js b/static/js/publisher-pages/pages/Releases/actions/channelMap.test.js similarity index 100% rename from static/js/publisher/release/actions/channelMap.test.js rename to static/js/publisher-pages/pages/Releases/actions/channelMap.test.js diff --git a/static/js/publisher/release/actions/currentTrack.js b/static/js/publisher-pages/pages/Releases/actions/currentTrack.js similarity index 100% rename from static/js/publisher/release/actions/currentTrack.js rename to static/js/publisher-pages/pages/Releases/actions/currentTrack.js diff --git a/static/js/publisher/release/actions/currentTrack.test.js b/static/js/publisher-pages/pages/Releases/actions/currentTrack.test.js similarity index 100% rename from static/js/publisher/release/actions/currentTrack.test.js rename to static/js/publisher-pages/pages/Releases/actions/currentTrack.test.js diff --git a/static/js/publisher/release/actions/defaultTrack.js b/static/js/publisher-pages/pages/Releases/actions/defaultTrack.js similarity index 100% rename from static/js/publisher/release/actions/defaultTrack.js rename to static/js/publisher-pages/pages/Releases/actions/defaultTrack.js diff --git a/static/js/publisher/release/actions/defaultTrack.test.js b/static/js/publisher-pages/pages/Releases/actions/defaultTrack.test.js similarity index 100% rename from static/js/publisher/release/actions/defaultTrack.test.js rename to static/js/publisher-pages/pages/Releases/actions/defaultTrack.test.js diff --git a/static/js/publisher/release/actions/gaEventTracking.js b/static/js/publisher-pages/pages/Releases/actions/gaEventTracking.js similarity index 92% rename from static/js/publisher/release/actions/gaEventTracking.js rename to static/js/publisher-pages/pages/Releases/actions/gaEventTracking.js index 21c02a4fc5..13c51c9eb5 100644 --- a/static/js/publisher/release/actions/gaEventTracking.js +++ b/static/js/publisher-pages/pages/Releases/actions/gaEventTracking.js @@ -1,4 +1,4 @@ -import { triggerEventReleaseUI } from "../../../base/ga"; +import { triggerEventReleaseUI } from "../../../../base/ga"; export function triggerGAEvent() { const eventLabelItems = [...arguments]; diff --git a/static/js/publisher/release/actions/gaEventTracking.test.js b/static/js/publisher-pages/pages/Releases/actions/gaEventTracking.test.js similarity index 100% rename from static/js/publisher/release/actions/gaEventTracking.test.js rename to static/js/publisher-pages/pages/Releases/actions/gaEventTracking.test.js diff --git a/static/js/publisher/release/actions/globalNotification.js b/static/js/publisher-pages/pages/Releases/actions/globalNotification.js similarity index 100% rename from static/js/publisher/release/actions/globalNotification.js rename to static/js/publisher-pages/pages/Releases/actions/globalNotification.js diff --git a/static/js/publisher/release/actions/globalNotification.test.js b/static/js/publisher-pages/pages/Releases/actions/globalNotification.test.js similarity index 100% rename from static/js/publisher/release/actions/globalNotification.test.js rename to static/js/publisher-pages/pages/Releases/actions/globalNotification.test.js diff --git a/static/js/publisher/release/actions/history.js b/static/js/publisher-pages/pages/Releases/actions/history.js similarity index 100% rename from static/js/publisher/release/actions/history.js rename to static/js/publisher-pages/pages/Releases/actions/history.js diff --git a/static/js/publisher/release/actions/history.test.js b/static/js/publisher-pages/pages/Releases/actions/history.test.js similarity index 100% rename from static/js/publisher/release/actions/history.test.js rename to static/js/publisher-pages/pages/Releases/actions/history.test.js diff --git a/static/js/publisher/release/actions/index.js b/static/js/publisher-pages/pages/Releases/actions/index.js similarity index 100% rename from static/js/publisher/release/actions/index.js rename to static/js/publisher-pages/pages/Releases/actions/index.js diff --git a/static/js/publisher/release/actions/modal.js b/static/js/publisher-pages/pages/Releases/actions/modal.js similarity index 100% rename from static/js/publisher/release/actions/modal.js rename to static/js/publisher-pages/pages/Releases/actions/modal.js diff --git a/static/js/publisher/release/actions/modal.test.js b/static/js/publisher-pages/pages/Releases/actions/modal.test.js similarity index 100% rename from static/js/publisher/release/actions/modal.test.js rename to static/js/publisher-pages/pages/Releases/actions/modal.test.js diff --git a/static/js/publisher/release/actions/pendingCloses.js b/static/js/publisher-pages/pages/Releases/actions/pendingCloses.js similarity index 100% rename from static/js/publisher/release/actions/pendingCloses.js rename to static/js/publisher-pages/pages/Releases/actions/pendingCloses.js diff --git a/static/js/publisher/release/actions/pendingCloses.test.js b/static/js/publisher-pages/pages/Releases/actions/pendingCloses.test.js similarity index 100% rename from static/js/publisher/release/actions/pendingCloses.test.js rename to static/js/publisher-pages/pages/Releases/actions/pendingCloses.test.js diff --git a/static/js/publisher/release/actions/pendingReleases.js b/static/js/publisher-pages/pages/Releases/actions/pendingReleases.js similarity index 100% rename from static/js/publisher/release/actions/pendingReleases.js rename to static/js/publisher-pages/pages/Releases/actions/pendingReleases.js diff --git a/static/js/publisher/release/actions/pendingReleases.test.js b/static/js/publisher-pages/pages/Releases/actions/pendingReleases.test.js similarity index 100% rename from static/js/publisher/release/actions/pendingReleases.test.js rename to static/js/publisher-pages/pages/Releases/actions/pendingReleases.test.js diff --git a/static/js/publisher/release/actions/releases.test.js b/static/js/publisher-pages/pages/Releases/actions/releases.test.js similarity index 100% rename from static/js/publisher/release/actions/releases.test.js rename to static/js/publisher-pages/pages/Releases/actions/releases.test.js diff --git a/static/js/publisher/release/actions/releases.ts b/static/js/publisher-pages/pages/Releases/actions/releases.ts similarity index 100% rename from static/js/publisher/release/actions/releases.ts rename to static/js/publisher-pages/pages/Releases/actions/releases.ts diff --git a/static/js/publisher/release/actions/revisions.js b/static/js/publisher-pages/pages/Releases/actions/revisions.js similarity index 100% rename from static/js/publisher/release/actions/revisions.js rename to static/js/publisher-pages/pages/Releases/actions/revisions.js diff --git a/static/js/publisher/release/actions/revisions.test.js b/static/js/publisher-pages/pages/Releases/actions/revisions.test.js similarity index 100% rename from static/js/publisher/release/actions/revisions.test.js rename to static/js/publisher-pages/pages/Releases/actions/revisions.test.js diff --git a/static/js/publisher/release/api/releases.js b/static/js/publisher-pages/pages/Releases/api/releases.js similarity index 100% rename from static/js/publisher/release/api/releases.js rename to static/js/publisher-pages/pages/Releases/api/releases.js diff --git a/static/js/publisher/release/components/ProgressiveReleaseProgressChart.tsx b/static/js/publisher-pages/pages/Releases/components/ProgressiveReleaseProgressChart.tsx similarity index 100% rename from static/js/publisher/release/components/ProgressiveReleaseProgressChart.tsx rename to static/js/publisher-pages/pages/Releases/components/ProgressiveReleaseProgressChart.tsx diff --git a/static/js/publisher/release/components/TrackInfo.test.tsx b/static/js/publisher-pages/pages/Releases/components/TrackInfo.test.tsx similarity index 100% rename from static/js/publisher/release/components/TrackInfo.test.tsx rename to static/js/publisher-pages/pages/Releases/components/TrackInfo.test.tsx diff --git a/static/js/publisher/release/components/TrackInfo.tsx b/static/js/publisher-pages/pages/Releases/components/TrackInfo.tsx similarity index 100% rename from static/js/publisher/release/components/TrackInfo.tsx rename to static/js/publisher-pages/pages/Releases/components/TrackInfo.tsx diff --git a/static/js/publisher/release/components/channelMenu.js b/static/js/publisher-pages/pages/Releases/components/channelMenu.js similarity index 100% rename from static/js/publisher/release/components/channelMenu.js rename to static/js/publisher-pages/pages/Releases/components/channelMenu.js diff --git a/static/js/publisher/release/components/contextualMenu.js b/static/js/publisher-pages/pages/Releases/components/contextualMenu.js similarity index 100% rename from static/js/publisher/release/components/contextualMenu.js rename to static/js/publisher-pages/pages/Releases/components/contextualMenu.js diff --git a/static/js/publisher/release/components/defaultTrackModifier.js b/static/js/publisher-pages/pages/Releases/components/defaultTrackModifier.js similarity index 100% rename from static/js/publisher/release/components/defaultTrackModifier.js rename to static/js/publisher-pages/pages/Releases/components/defaultTrackModifier.js diff --git a/static/js/publisher/release/components/dnd.tsx b/static/js/publisher-pages/pages/Releases/components/dnd.tsx similarity index 100% rename from static/js/publisher/release/components/dnd.tsx rename to static/js/publisher-pages/pages/Releases/components/dnd.tsx diff --git a/static/js/publisher/release/components/globalNotification.js b/static/js/publisher-pages/pages/Releases/components/globalNotification.js similarity index 100% rename from static/js/publisher/release/components/globalNotification.js rename to static/js/publisher-pages/pages/Releases/components/globalNotification.js diff --git a/static/js/publisher/release/components/historyIcon.js b/static/js/publisher-pages/pages/Releases/components/historyIcon.js similarity index 100% rename from static/js/publisher/release/components/historyIcon.js rename to static/js/publisher-pages/pages/Releases/components/historyIcon.js diff --git a/static/js/publisher/release/components/historyPanel.js b/static/js/publisher-pages/pages/Releases/components/historyPanel.js similarity index 100% rename from static/js/publisher/release/components/historyPanel.js rename to static/js/publisher-pages/pages/Releases/components/historyPanel.js diff --git a/static/js/publisher/release/components/modal.js b/static/js/publisher-pages/pages/Releases/components/modal.js similarity index 100% rename from static/js/publisher/release/components/modal.js rename to static/js/publisher-pages/pages/Releases/components/modal.js diff --git a/static/js/publisher/release/components/notification.js b/static/js/publisher-pages/pages/Releases/components/notification.js similarity index 100% rename from static/js/publisher/release/components/notification.js rename to static/js/publisher-pages/pages/Releases/components/notification.js diff --git a/static/js/publisher/release/components/progressiveBar.js b/static/js/publisher-pages/pages/Releases/components/progressiveBar.js similarity index 100% rename from static/js/publisher/release/components/progressiveBar.js rename to static/js/publisher-pages/pages/Releases/components/progressiveBar.js diff --git a/static/js/publisher/release/components/progressiveConfirm.js b/static/js/publisher-pages/pages/Releases/components/progressiveConfirm.js similarity index 100% rename from static/js/publisher/release/components/progressiveConfirm.js rename to static/js/publisher-pages/pages/Releases/components/progressiveConfirm.js diff --git a/static/js/publisher/release/components/releasesConfirm.tsx b/static/js/publisher-pages/pages/Releases/components/releasesConfirm.tsx similarity index 99% rename from static/js/publisher/release/components/releasesConfirm.tsx rename to static/js/publisher-pages/pages/Releases/components/releasesConfirm.tsx index 26482af010..ffa16460d9 100644 --- a/static/js/publisher/release/components/releasesConfirm.tsx +++ b/static/js/publisher-pages/pages/Releases/components/releasesConfirm.tsx @@ -1,7 +1,7 @@ import { Component, createRef } from "react"; import { connect } from "react-redux"; -import debounce from "../../../libs/debounce"; +import debounce from "../../../../libs/debounce"; import ReleasesConfirmDetails from "./releasesConfirmDetails/"; import ReleasesConfirmActions from "./releasesConfirmActions"; diff --git a/static/js/publisher/release/components/releasesConfirmActions.js b/static/js/publisher-pages/pages/Releases/components/releasesConfirmActions.js similarity index 100% rename from static/js/publisher/release/components/releasesConfirmActions.js rename to static/js/publisher-pages/pages/Releases/components/releasesConfirmActions.js diff --git a/static/js/publisher/release/components/releasesConfirmDetails/cancelProgressiveRow.js b/static/js/publisher-pages/pages/Releases/components/releasesConfirmDetails/cancelProgressiveRow.js similarity index 100% rename from static/js/publisher/release/components/releasesConfirmDetails/cancelProgressiveRow.js rename to static/js/publisher-pages/pages/Releases/components/releasesConfirmDetails/cancelProgressiveRow.js diff --git a/static/js/publisher/release/components/releasesConfirmDetails/closeChannelsRow.js b/static/js/publisher-pages/pages/Releases/components/releasesConfirmDetails/closeChannelsRow.js similarity index 100% rename from static/js/publisher/release/components/releasesConfirmDetails/closeChannelsRow.js rename to static/js/publisher-pages/pages/Releases/components/releasesConfirmDetails/closeChannelsRow.js diff --git a/static/js/publisher/release/components/releasesConfirmDetails/globalRow.js b/static/js/publisher-pages/pages/Releases/components/releasesConfirmDetails/globalRow.js similarity index 100% rename from static/js/publisher/release/components/releasesConfirmDetails/globalRow.js rename to static/js/publisher-pages/pages/Releases/components/releasesConfirmDetails/globalRow.js diff --git a/static/js/publisher/release/components/releasesConfirmDetails/index.js b/static/js/publisher-pages/pages/Releases/components/releasesConfirmDetails/index.js similarity index 100% rename from static/js/publisher/release/components/releasesConfirmDetails/index.js rename to static/js/publisher-pages/pages/Releases/components/releasesConfirmDetails/index.js diff --git a/static/js/publisher/release/components/releasesConfirmDetails/progressiveRow.js b/static/js/publisher-pages/pages/Releases/components/releasesConfirmDetails/progressiveRow.js similarity index 100% rename from static/js/publisher/release/components/releasesConfirmDetails/progressiveRow.js rename to static/js/publisher-pages/pages/Releases/components/releasesConfirmDetails/progressiveRow.js diff --git a/static/js/publisher/release/components/releasesConfirmDetails/progressiveRowGroup.js b/static/js/publisher-pages/pages/Releases/components/releasesConfirmDetails/progressiveRowGroup.js similarity index 100% rename from static/js/publisher/release/components/releasesConfirmDetails/progressiveRowGroup.js rename to static/js/publisher-pages/pages/Releases/components/releasesConfirmDetails/progressiveRowGroup.js diff --git a/static/js/publisher/release/components/releasesConfirmDetails/releaseRow.js b/static/js/publisher-pages/pages/Releases/components/releasesConfirmDetails/releaseRow.js similarity index 100% rename from static/js/publisher/release/components/releasesConfirmDetails/releaseRow.js rename to static/js/publisher-pages/pages/Releases/components/releasesConfirmDetails/releaseRow.js diff --git a/static/js/publisher/release/components/releasesConfirmDetails/types.js b/static/js/publisher-pages/pages/Releases/components/releasesConfirmDetails/types.js similarity index 100% rename from static/js/publisher/release/components/releasesConfirmDetails/types.js rename to static/js/publisher-pages/pages/Releases/components/releasesConfirmDetails/types.js diff --git a/static/js/publisher-pages/pages/Releases/components/releasesHeading.js b/static/js/publisher-pages/pages/Releases/components/releasesHeading.js new file mode 100644 index 0000000000..b069c11c2c --- /dev/null +++ b/static/js/publisher-pages/pages/Releases/components/releasesHeading.js @@ -0,0 +1,593 @@ +import React, { useState } from "react"; +import { useQuery } from "react-query"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import { + Row, + Col, + Button, + Icon, + Form, + Notification, + Accordion, +} from "@canonical/react-components"; + +import { setCurrentTrack } from "../actions/currentTrack"; +import { closeHistory } from "../actions/history"; +import { getTracks } from "../selectors"; +import { + validatePhasingPercentage, + resizeAsidePanel, + numericalSort, + getPackageMetadata, +} from "../helpers"; + +import DefaultTrackModifier from "./defaultTrackModifier"; +import ReleasesTable from "./releasesTable"; +import TrackInfo from "./TrackInfo"; + +function ReleasesHeading(props) { + resizeAsidePanel("request"); + resizeAsidePanel("add"); + + const [isOpen, setIsOpen] = useState(false); + + const [requestTrackSidePanelOpen, setRequestTrackSidePanelOpen] = + useState(false); + + const [addTrackSidePanelOpen, setAddTrackSidePanelOpen] = useState(false); + + const openRequestTrackSidePanel = () => { + setRequestTrackSidePanelOpen(true); + }; + + const closeRequestTrackSidePanel = () => { + setRequestTrackSidePanelOpen(false); + }; + + const openAddTrackSidePanel = () => { + setAddTrackSidePanelOpen(true); + }; + + const closeAddTrackSidePanel = () => { + setTrackName(""); + setVersionPattern(""); + setPhasingPercentage(""); + setPhasingPercentageError(""); + setTrackNameError(""); + setAddTrackSidePanelOpen(false); + }; + + const handleToggle = () => { + setIsOpen(!isOpen); + }; + + const handleSelect = (track) => { + props.setCurrentTrack(track); + setIsOpen(false); + }; + + const { tracks, currentTrack, defaultTrack } = props; + tracks.sort(numericalSort); + const options = tracks.map((track) => ({ value: track, label: track })); + + const [trackName, setTrackName] = useState(""); + const [versionPattern, setVersionPattern] = useState(""); + const [phasingPercentage, setPhasingPercentage] = useState(""); + const [phasingPercentageError, setPhasingPercentageError] = useState(""); + + const isTrackNameFilled = trackName.trim().length > 0; + const [isLoading, setIsLoading] = useState(false); + const [notification, setNotification] = useState(null); + + const { data, guardrailsLoading, error } = useQuery( + ["snapData", props.snapName], + async () => { + const response = await getPackageMetadata(props.snapName); + return response; + }, + { enabled: !!props.snapName }, + ); + + const storedTracks = data?.tracks || []; + const trackGuardrailsData = data?.["track-guardrails"]; + + const trackInfo = storedTracks.find( + (track) => track.name === props.currentTrack, + ); + const currentTrackVersionPattern = trackInfo?.["version-pattern"] || null; + const currentPhasingPercentage = + trackInfo?.["automatic-phasing-percentage"] || null; + + let trackGuardrailsStatus = null; + + if (guardrailsLoading) { + trackGuardrailsStatus = null; + } else if (error) { + trackGuardrailsStatus = "error"; + } else if (!trackGuardrailsData) { + trackGuardrailsStatus = "no-guardrails"; + } else if (trackGuardrailsData.length === 0) { + trackGuardrailsStatus = "request"; + } else { + trackGuardrailsStatus = "add"; + } + + const handleTrackNameChange = (event) => { + setTrackNameError(""); + const { value } = event.target; + setTrackName(value); + }; + + const handleVersionPatternChange = (event) => { + setVersionPattern(event.target.value); + }; + + const handlePhasingPercentageChange = (event) => { + const { value } = event.target; + setPhasingPercentage(value); + const error = validatePhasingPercentage(value); + setPhasingPercentageError(error); + }; + + const showNotification = (type, message) => { + setNotification({ type, message }); + }; + + const [successNotification, setSuccessNotification] = useState(null); + const [trackNameError, setTrackNameError] = useState(""); + + const handleAddTrack = async () => { + try { + setIsLoading(true); + + const formData = new URLSearchParams(); + formData.append("track-name", trackName); + + if (versionPattern.trim() !== "") { + formData.append("version-pattern", versionPattern); + } + + if (phasingPercentage.trim() !== "" && !phasingPercentageError) { + formData.append("automatic-phasing-percentage", phasingPercentage); + } + + const response = await fetch(`/${props.snapName}/create-track`, { + method: "POST", + body: formData, + }); + + const responseData = await response.json(); + + if (response.ok) { + props.setCurrentTrack(trackName); + closeAddTrackSidePanel(); + setSuccessNotification(`Track ${trackName} created successfully`); + } else { + let errorMessage = responseData.error; + if (responseData && responseData["error-list"]) { + const error = responseData["error-list"][0]; + if (error && error.message) { + errorMessage = error.message; + } + } + setTrackNameError(errorMessage); + } + } catch (error) { + console.error("Error:", error.message); + showNotification("Error", error.message); + } finally { + setIsLoading(false); + } + }; + + const dropdownPaddingClass = + trackGuardrailsStatus === "error" + ? "dropdown-menu padding-bottom" + : "dropdown-menu no-padding-bottom"; + + return ( + <> +
+
+
+

Releases available to install

+
+ + +
+ +
+ +
+ {successNotification && ( + + {successNotification} + + )} +
+ +
+ {} +
+
+
+ +
+ + {/* Request track aside panel */} + +
+ + + {/* Add track aside panel */} + +
+
+ + + ); +} + +ReleasesHeading.propTypes = { + tracks: PropTypes.array.isRequired, + setCurrentTrack: PropTypes.func.isRequired, + closeHistoryPanel: PropTypes.func.isRequired, + currentTrack: PropTypes.string.isRequired, + defaultTrack: PropTypes.string, + snapName: PropTypes.string.isRequired, +}; + +const mapStateToProps = (state) => { + return { + tracks: getTracks(state), + currentTrack: state.currentTrack, + defaultTrack: state.defaultTrack, + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + setCurrentTrack: (track) => dispatch(setCurrentTrack(track)), + closeHistoryPanel: () => dispatch(closeHistory()), + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(ReleasesHeading); diff --git a/static/js/publisher/release/components/releasesTable/cellViews.js b/static/js/publisher-pages/pages/Releases/components/releasesTable/cellViews.js similarity index 100% rename from static/js/publisher/release/components/releasesTable/cellViews.js rename to static/js/publisher-pages/pages/Releases/components/releasesTable/cellViews.js diff --git a/static/js/publisher/release/components/releasesTable/channelHeading.tsx b/static/js/publisher-pages/pages/Releases/components/releasesTable/channelHeading.tsx similarity index 99% rename from static/js/publisher/release/components/releasesTable/channelHeading.tsx rename to static/js/publisher-pages/pages/Releases/components/releasesTable/channelHeading.tsx index 5d038d3bd2..598b70efe9 100644 --- a/static/js/publisher/release/components/releasesTable/channelHeading.tsx +++ b/static/js/publisher-pages/pages/Releases/components/releasesTable/channelHeading.tsx @@ -2,7 +2,7 @@ import { Fragment } from "react"; import { connect } from "react-redux"; import { format, formatDistanceToNow } from "date-fns"; -import { sortChannels } from "../../../../libs/channels"; +import { sortChannels } from "../../../../../libs/channels.js"; import { getArchitectures, diff --git a/static/js/publisher/release/components/releasesTable/channelRow.js b/static/js/publisher-pages/pages/Releases/components/releasesTable/channelRow.js similarity index 100% rename from static/js/publisher/release/components/releasesTable/channelRow.js rename to static/js/publisher-pages/pages/Releases/components/releasesTable/channelRow.js diff --git a/static/js/publisher/release/components/releasesTable/droppableRow.tsx b/static/js/publisher-pages/pages/Releases/components/releasesTable/droppableRow.tsx similarity index 100% rename from static/js/publisher/release/components/releasesTable/droppableRow.tsx rename to static/js/publisher-pages/pages/Releases/components/releasesTable/droppableRow.tsx diff --git a/static/js/publisher/release/components/releasesTable/index.js b/static/js/publisher-pages/pages/Releases/components/releasesTable/index.js similarity index 100% rename from static/js/publisher/release/components/releasesTable/index.js rename to static/js/publisher-pages/pages/Releases/components/releasesTable/index.js diff --git a/static/js/publisher/release/components/releasesTable/releaseCell.js b/static/js/publisher-pages/pages/Releases/components/releasesTable/releaseCell.js similarity index 100% rename from static/js/publisher/release/components/releasesTable/releaseCell.js rename to static/js/publisher-pages/pages/Releases/components/releasesTable/releaseCell.js diff --git a/static/js/publisher/release/components/releasesTable/releaseMenuItem.js b/static/js/publisher-pages/pages/Releases/components/releasesTable/releaseMenuItem.js similarity index 100% rename from static/js/publisher/release/components/releasesTable/releaseMenuItem.js rename to static/js/publisher-pages/pages/Releases/components/releasesTable/releaseMenuItem.js diff --git a/static/js/publisher/release/components/releasesTable/revisionCell.js b/static/js/publisher-pages/pages/Releases/components/releasesTable/revisionCell.js similarity index 100% rename from static/js/publisher/release/components/releasesTable/revisionCell.js rename to static/js/publisher-pages/pages/Releases/components/releasesTable/revisionCell.js diff --git a/static/js/publisher/release/components/releasesTable/revisionsRow.js b/static/js/publisher-pages/pages/Releases/components/releasesTable/revisionsRow.js similarity index 100% rename from static/js/publisher/release/components/releasesTable/revisionsRow.js rename to static/js/publisher-pages/pages/Releases/components/releasesTable/revisionsRow.js diff --git a/static/js/publisher/release/components/releasesTable/row.tsx b/static/js/publisher-pages/pages/Releases/components/releasesTable/row.tsx similarity index 100% rename from static/js/publisher/release/components/releasesTable/row.tsx rename to static/js/publisher-pages/pages/Releases/components/releasesTable/row.tsx diff --git a/static/js/publisher/release/components/revisionLabel.js b/static/js/publisher-pages/pages/Releases/components/revisionLabel.js similarity index 100% rename from static/js/publisher/release/components/revisionLabel.js rename to static/js/publisher-pages/pages/Releases/components/revisionLabel.js diff --git a/static/js/publisher/release/components/revisionsList.js b/static/js/publisher-pages/pages/Releases/components/revisionsList.js similarity index 100% rename from static/js/publisher/release/components/revisionsList.js rename to static/js/publisher-pages/pages/Releases/components/revisionsList.js diff --git a/static/js/publisher/release/components/revisionsListRow.js b/static/js/publisher-pages/pages/Releases/components/revisionsListRow.js similarity index 98% rename from static/js/publisher/release/components/revisionsListRow.js rename to static/js/publisher-pages/pages/Releases/components/revisionsListRow.js index 325d60300a..f59bc2d86f 100644 --- a/static/js/publisher/release/components/revisionsListRow.js +++ b/static/js/publisher-pages/pages/Releases/components/revisionsListRow.js @@ -6,7 +6,7 @@ import { connect } from "react-redux"; import { format } from "date-fns"; import { canBeReleased } from "../helpers"; -import { getChannelString } from "../../../libs/channels"; +import { getChannelString } from "../../../../libs/channels.js"; import { toggleRevision } from "../actions/channelMap"; import { @@ -104,8 +104,8 @@ const RevisionsListRow = (props) => { )} - {revision.version} diff --git a/static/js/publisher/release/constants.js b/static/js/publisher-pages/pages/Releases/constants.js similarity index 100% rename from static/js/publisher/release/constants.js rename to static/js/publisher-pages/pages/Releases/constants.js diff --git a/static/js/publisher/release/helpers.test.js b/static/js/publisher-pages/pages/Releases/helpers.test.js similarity index 100% rename from static/js/publisher/release/helpers.test.js rename to static/js/publisher-pages/pages/Releases/helpers.test.js diff --git a/static/js/publisher/release/helpers.ts b/static/js/publisher-pages/pages/Releases/helpers.ts similarity index 98% rename from static/js/publisher/release/helpers.ts rename to static/js/publisher-pages/pages/Releases/helpers.ts index c77bb68f30..33d9875170 100644 --- a/static/js/publisher/release/helpers.ts +++ b/static/js/publisher-pages/pages/Releases/helpers.ts @@ -1,5 +1,5 @@ import { AVAILABLE, REVISION_STATUS } from "./constants"; -import { getChannelString } from "../../libs/channels"; +import { getChannelString } from "../../../libs/channels"; import { useEffect } from "react"; export function isInDevmode(revision: any) { diff --git a/static/js/publisher-pages/pages/Releases/index.ts b/static/js/publisher-pages/pages/Releases/index.ts new file mode 100644 index 0000000000..b0c2d072b6 --- /dev/null +++ b/static/js/publisher-pages/pages/Releases/index.ts @@ -0,0 +1 @@ +export { default } from "./Releases"; diff --git a/static/js/publisher/release/reducers/architectures.js b/static/js/publisher-pages/pages/Releases/reducers/architectures.js similarity index 100% rename from static/js/publisher/release/reducers/architectures.js rename to static/js/publisher-pages/pages/Releases/reducers/architectures.js diff --git a/static/js/publisher/release/reducers/architectures.test.js b/static/js/publisher-pages/pages/Releases/reducers/architectures.test.js similarity index 100% rename from static/js/publisher/release/reducers/architectures.test.js rename to static/js/publisher-pages/pages/Releases/reducers/architectures.test.js diff --git a/static/js/publisher/release/reducers/availableRevisionsSelect.js b/static/js/publisher-pages/pages/Releases/reducers/availableRevisionsSelect.js similarity index 100% rename from static/js/publisher/release/reducers/availableRevisionsSelect.js rename to static/js/publisher-pages/pages/Releases/reducers/availableRevisionsSelect.js diff --git a/static/js/publisher/release/reducers/availableRevisionsSelect.test.js b/static/js/publisher-pages/pages/Releases/reducers/availableRevisionsSelect.test.js similarity index 100% rename from static/js/publisher/release/reducers/availableRevisionsSelect.test.js rename to static/js/publisher-pages/pages/Releases/reducers/availableRevisionsSelect.test.js diff --git a/static/js/publisher/release/reducers/branches.js b/static/js/publisher-pages/pages/Releases/reducers/branches.js similarity index 100% rename from static/js/publisher/release/reducers/branches.js rename to static/js/publisher-pages/pages/Releases/reducers/branches.js diff --git a/static/js/publisher/release/reducers/branches.test.js b/static/js/publisher-pages/pages/Releases/reducers/branches.test.js similarity index 100% rename from static/js/publisher/release/reducers/branches.test.js rename to static/js/publisher-pages/pages/Releases/reducers/branches.test.js diff --git a/static/js/publisher/release/reducers/channelMap.js b/static/js/publisher-pages/pages/Releases/reducers/channelMap.js similarity index 100% rename from static/js/publisher/release/reducers/channelMap.js rename to static/js/publisher-pages/pages/Releases/reducers/channelMap.js diff --git a/static/js/publisher/release/reducers/channelMap.test.js b/static/js/publisher-pages/pages/Releases/reducers/channelMap.test.js similarity index 100% rename from static/js/publisher/release/reducers/channelMap.test.js rename to static/js/publisher-pages/pages/Releases/reducers/channelMap.test.js diff --git a/static/js/publisher/release/reducers/currentTrack.js b/static/js/publisher-pages/pages/Releases/reducers/currentTrack.js similarity index 100% rename from static/js/publisher/release/reducers/currentTrack.js rename to static/js/publisher-pages/pages/Releases/reducers/currentTrack.js diff --git a/static/js/publisher/release/reducers/currentTrack.test.js b/static/js/publisher-pages/pages/Releases/reducers/currentTrack.test.js similarity index 100% rename from static/js/publisher/release/reducers/currentTrack.test.js rename to static/js/publisher-pages/pages/Releases/reducers/currentTrack.test.js diff --git a/static/js/publisher/release/reducers/defaultTrack.js b/static/js/publisher-pages/pages/Releases/reducers/defaultTrack.js similarity index 100% rename from static/js/publisher/release/reducers/defaultTrack.js rename to static/js/publisher-pages/pages/Releases/reducers/defaultTrack.js diff --git a/static/js/publisher/release/reducers/defaultTrack.test.js b/static/js/publisher-pages/pages/Releases/reducers/defaultTrack.test.js similarity index 100% rename from static/js/publisher/release/reducers/defaultTrack.test.js rename to static/js/publisher-pages/pages/Releases/reducers/defaultTrack.test.js diff --git a/static/js/publisher/release/reducers/globalNotification.js b/static/js/publisher-pages/pages/Releases/reducers/globalNotification.js similarity index 100% rename from static/js/publisher/release/reducers/globalNotification.js rename to static/js/publisher-pages/pages/Releases/reducers/globalNotification.js diff --git a/static/js/publisher/release/reducers/globalNotification.test.js b/static/js/publisher-pages/pages/Releases/reducers/globalNotification.test.js similarity index 100% rename from static/js/publisher/release/reducers/globalNotification.test.js rename to static/js/publisher-pages/pages/Releases/reducers/globalNotification.test.js diff --git a/static/js/publisher/release/reducers/history.js b/static/js/publisher-pages/pages/Releases/reducers/history.js similarity index 100% rename from static/js/publisher/release/reducers/history.js rename to static/js/publisher-pages/pages/Releases/reducers/history.js diff --git a/static/js/publisher/release/reducers/history.test.js b/static/js/publisher-pages/pages/Releases/reducers/history.test.js similarity index 100% rename from static/js/publisher/release/reducers/history.test.js rename to static/js/publisher-pages/pages/Releases/reducers/history.test.js diff --git a/static/js/publisher/release/reducers/index.js b/static/js/publisher-pages/pages/Releases/reducers/index.js similarity index 100% rename from static/js/publisher/release/reducers/index.js rename to static/js/publisher-pages/pages/Releases/reducers/index.js diff --git a/static/js/publisher/release/reducers/modal.js b/static/js/publisher-pages/pages/Releases/reducers/modal.js similarity index 100% rename from static/js/publisher/release/reducers/modal.js rename to static/js/publisher-pages/pages/Releases/reducers/modal.js diff --git a/static/js/publisher/release/reducers/modal.test.js b/static/js/publisher-pages/pages/Releases/reducers/modal.test.js similarity index 100% rename from static/js/publisher/release/reducers/modal.test.js rename to static/js/publisher-pages/pages/Releases/reducers/modal.test.js diff --git a/static/js/publisher/release/reducers/options.js b/static/js/publisher-pages/pages/Releases/reducers/options.js similarity index 100% rename from static/js/publisher/release/reducers/options.js rename to static/js/publisher-pages/pages/Releases/reducers/options.js diff --git a/static/js/publisher/release/reducers/pendingCloses.js b/static/js/publisher-pages/pages/Releases/reducers/pendingCloses.js similarity index 100% rename from static/js/publisher/release/reducers/pendingCloses.js rename to static/js/publisher-pages/pages/Releases/reducers/pendingCloses.js diff --git a/static/js/publisher/release/reducers/pendingCloses.test.js b/static/js/publisher-pages/pages/Releases/reducers/pendingCloses.test.js similarity index 100% rename from static/js/publisher/release/reducers/pendingCloses.test.js rename to static/js/publisher-pages/pages/Releases/reducers/pendingCloses.test.js diff --git a/static/js/publisher/release/reducers/pendingReleases.test.js b/static/js/publisher-pages/pages/Releases/reducers/pendingReleases.test.js similarity index 100% rename from static/js/publisher/release/reducers/pendingReleases.test.js rename to static/js/publisher-pages/pages/Releases/reducers/pendingReleases.test.js diff --git a/static/js/publisher/release/reducers/pendingReleases.ts b/static/js/publisher-pages/pages/Releases/reducers/pendingReleases.ts similarity index 100% rename from static/js/publisher/release/reducers/pendingReleases.ts rename to static/js/publisher-pages/pages/Releases/reducers/pendingReleases.ts diff --git a/static/js/publisher/release/reducers/releases.js b/static/js/publisher-pages/pages/Releases/reducers/releases.js similarity index 100% rename from static/js/publisher/release/reducers/releases.js rename to static/js/publisher-pages/pages/Releases/reducers/releases.js diff --git a/static/js/publisher/release/reducers/releases.test.js b/static/js/publisher-pages/pages/Releases/reducers/releases.test.js similarity index 100% rename from static/js/publisher/release/reducers/releases.test.js rename to static/js/publisher-pages/pages/Releases/reducers/releases.test.js diff --git a/static/js/publisher/release/reducers/revisions.js b/static/js/publisher-pages/pages/Releases/reducers/revisions.js similarity index 100% rename from static/js/publisher/release/reducers/revisions.js rename to static/js/publisher-pages/pages/Releases/reducers/revisions.js diff --git a/static/js/publisher/release/reducers/revisions.test.js b/static/js/publisher-pages/pages/Releases/reducers/revisions.test.js similarity index 100% rename from static/js/publisher/release/reducers/revisions.test.js rename to static/js/publisher-pages/pages/Releases/reducers/revisions.test.js diff --git a/static/js/publisher/release/releasesController.js b/static/js/publisher-pages/pages/Releases/releasesController.js similarity index 100% rename from static/js/publisher/release/releasesController.js rename to static/js/publisher-pages/pages/Releases/releasesController.js diff --git a/static/js/publisher/release/releasesState.js b/static/js/publisher-pages/pages/Releases/releasesState.js similarity index 100% rename from static/js/publisher/release/releasesState.js rename to static/js/publisher-pages/pages/Releases/releasesState.js diff --git a/static/js/publisher/release/selectors/index.ts b/static/js/publisher-pages/pages/Releases/selectors/index.ts similarity index 99% rename from static/js/publisher/release/selectors/index.ts rename to static/js/publisher-pages/pages/Releases/selectors/index.ts index 3526defe66..5bf8af8eae 100644 --- a/static/js/publisher/release/selectors/index.ts +++ b/static/js/publisher-pages/pages/Releases/selectors/index.ts @@ -11,7 +11,7 @@ import { isRevisionBuiltOnLauchpad, jsonClone, } from "../helpers"; -import { sortAlphaNum, getChannelString } from "../../../libs/channels"; +import { sortAlphaNum, getChannelString } from "../../../../libs/channels"; import { CombinedState } from "redux"; // returns true if isProgressiveReleaseEnabled feature flag is enabled diff --git a/static/js/publisher/release/selectors/selectors.test.js b/static/js/publisher-pages/pages/Releases/selectors/selectors.test.js similarity index 100% rename from static/js/publisher/release/selectors/selectors.test.js rename to static/js/publisher-pages/pages/Releases/selectors/selectors.test.js diff --git a/static/js/publisher-pages/types/releaseTypes.d.ts b/static/js/publisher-pages/types/releaseTypes.d.ts new file mode 100644 index 0000000000..79b29998d2 --- /dev/null +++ b/static/js/publisher-pages/types/releaseTypes.d.ts @@ -0,0 +1,92 @@ +export interface Progressive { + "current-percentage": number | null; + paused: boolean | null; + percentage: number | null; +} + +export interface Release { + architecture: string; + branch: string | null; + channel: string; + "expiration-date": string | null; + progressive: Progressive; + revision: number | null; + risk: string; + track: string; + when: string; +} + +export interface Revision { + architectures: string[]; + attributes: { [key: string]: string }; + base: string; + build_url: string | null; + confinement: string; + created_at: string; + epoch: { + read: number[]; + write: number[]; + }; + grade: string; + revision: number; + sha3_384: string; + size: number; + status: string; + version: string; +} + +export interface Channel { + branch: string | null; + fallback: string | null; + name: string; + risk: string; + track: string; +} + +export interface Track { + "creation-date": string | null; + name: string; + status: string; + "version-pattern": string | null; +} + +export interface Snap { + channels: Channel[]; + "default-track": string | null; + id: string; + name: string; + private: boolean; + publisher: { + "display-name": string; + id: string; + username: string; + }; + title: string; + tracks: Track[]; +} + +export interface ReleasesData { + _links: { + self: string; + }; + releases: Release[]; + revisions: Revision[]; + snap: Snap; +} + +export interface ChannelMap { + architecture: string; + channel: string; + "expiration-date": string | null; + progressive: Progressive; + revision: number | null; + when: string; +} + +export interface Options { + defaultTrack: string; + csrfToken: string; + flags: { + isProgressiveReleaseEnabled: boolean; + }; +} diff --git a/static/js/publisher/release/components/releasesHeading.js b/static/js/publisher/release/components/releasesHeading.js deleted file mode 100644 index c8aee2a5ac..0000000000 --- a/static/js/publisher/release/components/releasesHeading.js +++ /dev/null @@ -1,595 +0,0 @@ -import React, { useState } from "react"; -import { useQuery } from "react-query"; -import PropTypes from "prop-types"; -import { connect } from "react-redux"; -import { - Row, - Col, - Button, - Icon, - Form, - Notification, - Accordion, -} from "@canonical/react-components"; - -import { setCurrentTrack } from "../actions/currentTrack"; -import { closeHistory } from "../actions/history"; -import { getTracks } from "../selectors"; -import { - validatePhasingPercentage, - resizeAsidePanel, - numericalSort, - getPackageMetadata, -} from "../helpers"; - -import DefaultTrackModifier from "./defaultTrackModifier"; -import ReleasesTable from "./releasesTable"; -import TrackInfo from "./TrackInfo"; - -function ReleasesHeading(props) { - resizeAsidePanel("request"); - resizeAsidePanel("add"); - - const [isOpen, setIsOpen] = useState(false); - - const [requestTrackSidePanelOpen, setRequestTrackSidePanelOpen] = - useState(false); - - const [addTrackSidePanelOpen, setAddTrackSidePanelOpen] = useState(false); - - const openRequestTrackSidePanel = () => { - setRequestTrackSidePanelOpen(true); - }; - - const closeRequestTrackSidePanel = () => { - setRequestTrackSidePanelOpen(false); - }; - - const openAddTrackSidePanel = () => { - setAddTrackSidePanelOpen(true); - }; - - const closeAddTrackSidePanel = () => { - setTrackName(""); - setVersionPattern(""); - setPhasingPercentage(""); - setPhasingPercentageError(""); - setTrackNameError(""); - setAddTrackSidePanelOpen(false); - }; - - const handleToggle = () => { - setIsOpen(!isOpen); - }; - - const handleSelect = (track) => { - props.setCurrentTrack(track); - setIsOpen(false); - }; - - const { tracks, currentTrack, defaultTrack } = props; - tracks.sort(numericalSort); - const options = tracks.map((track) => ({ value: track, label: track })); - - const [trackName, setTrackName] = useState(""); - const [versionPattern, setVersionPattern] = useState(""); - const [phasingPercentage, setPhasingPercentage] = useState(""); - const [phasingPercentageError, setPhasingPercentageError] = useState(""); - - const isTrackNameFilled = trackName.trim().length > 0; - const [isLoading, setIsLoading] = useState(false); - const [notification, setNotification] = useState(null); - - const { data, guardrailsLoading, error } = useQuery( - ["snapData", props.snapName], - async () => { - const response = await getPackageMetadata(props.snapName); - return response; - }, - { enabled: !!props.snapName }, - ); - - const storedTracks = data?.tracks || []; - const trackGuardrailsData = data?.["track-guardrails"]; - - const trackInfo = storedTracks.find( - (track) => track.name === props.currentTrack, - ); - const currentTrackVersionPattern = trackInfo?.["version-pattern"] || null; - const currentPhasingPercentage = - trackInfo?.["automatic-phasing-percentage"] || null; - - let trackGuardrailsStatus = null; - - if (guardrailsLoading) { - trackGuardrailsStatus = null; - } else if (error) { - trackGuardrailsStatus = "error"; - } else if (!trackGuardrailsData) { - trackGuardrailsStatus = "no-guardrails"; - } else if (trackGuardrailsData.length === 0) { - trackGuardrailsStatus = "request"; - } else { - trackGuardrailsStatus = "add"; - } - - const handleTrackNameChange = (event) => { - setTrackNameError(""); - const { value } = event.target; - setTrackName(value); - }; - - const handleVersionPatternChange = (event) => { - setVersionPattern(event.target.value); - }; - - const handlePhasingPercentageChange = (event) => { - const { value } = event.target; - setPhasingPercentage(value); - const error = validatePhasingPercentage(value); - setPhasingPercentageError(error); - }; - - const showNotification = (type, message) => { - setNotification({ type, message }); - }; - - const [successNotification, setSuccessNotification] = useState(null); - const [trackNameError, setTrackNameError] = useState(""); - - const handleAddTrack = async () => { - try { - setIsLoading(true); - - const formData = new URLSearchParams(); - formData.append("track-name", trackName); - - if (versionPattern.trim() !== "") { - formData.append("version-pattern", versionPattern); - } - - if (phasingPercentage.trim() !== "" && !phasingPercentageError) { - formData.append("automatic-phasing-percentage", phasingPercentage); - } - - const response = await fetch(`/${props.snapName}/create-track`, { - method: "POST", - body: formData, - }); - - const responseData = await response.json(); - - if (response.ok) { - props.setCurrentTrack(trackName); - closeAddTrackSidePanel(); - setSuccessNotification(`Track ${trackName} created successfully`); - } else { - let errorMessage = responseData.error; - if (responseData && responseData["error-list"]) { - const error = responseData["error-list"][0]; - if (error && error.message) { - errorMessage = error.message; - } - } - setTrackNameError(errorMessage); - } - } catch (error) { - console.error("Error:", error.message); - showNotification("Error", error.message); - } finally { - setIsLoading(false); - } - }; - - const dropdownPaddingClass = - trackGuardrailsStatus === "error" - ? "dropdown-menu padding-bottom" - : "dropdown-menu no-padding-bottom"; - - return ( -
-
-
-
-
-

Releases available to install

-
- - -
- -
- -
- {successNotification && ( - - {successNotification} - - )} -
- -
- {} -
-
-
- -
- - {/* Request track aside panel */} - -
- - - {/* Add track aside panel */} - -
-
- -
-
- ); -} - -ReleasesHeading.propTypes = { - tracks: PropTypes.array.isRequired, - setCurrentTrack: PropTypes.func.isRequired, - closeHistoryPanel: PropTypes.func.isRequired, - currentTrack: PropTypes.string.isRequired, - defaultTrack: PropTypes.string, - snapName: PropTypes.string.isRequired, -}; - -const mapStateToProps = (state) => { - return { - tracks: getTracks(state), - currentTrack: state.currentTrack, - defaultTrack: state.defaultTrack, - }; -}; - -const mapDispatchToProps = (dispatch) => { - return { - setCurrentTrack: (track) => dispatch(setCurrentTrack(track)), - closeHistoryPanel: () => dispatch(closeHistory()), - }; -}; - -export default connect(mapStateToProps, mapDispatchToProps)(ReleasesHeading); diff --git a/templates/publisher/release-history.html b/templates/publisher/release-history.html deleted file mode 100644 index 3403ffbc1c..0000000000 --- a/templates/publisher/release-history.html +++ /dev/null @@ -1,57 +0,0 @@ -{% extends "publisher/_publisher_layout.html" %} - -{% block meta_title %} -Releases and revision history for {% if snap_title %}{{ snap_title }}{% else %}{{ snap_name }}{% endif %} -{% endblock %} - -{% block content %} -
- {% set selected_tab='release' %} - {% include "publisher/_header.html" %} - -
- {% if release_history["error-list"] %} -
-
-
-

- {% for error in release_history["error-list"] %} - {{ error.code }}: {{ error.message }}
- {% endfor %} -

-
-
-
- {% endif %} -
-
- -{% endblock %} - -{% block scripts_includes %} - -{% endblock %} - -{% block scripts %} - {% if "error-list" not in release_history %} - - {% endif %} -{% endblock %} diff --git a/tests/publisher/snaps/tests_revision.py b/tests/publisher/snaps/tests_revision.py deleted file mode 100644 index 001135eb81..0000000000 --- a/tests/publisher/snaps/tests_revision.py +++ /dev/null @@ -1,87 +0,0 @@ -import responses -from tests.publisher.endpoint_testing import BaseTestCases - - -class RevisionHistoryPageNotAuth(BaseTestCases.EndpointLoggedOut): - def setUp(self): - snap_name = "test-snap" - endpoint_url = "/{}/releases".format(snap_name) - - super().setUp(snap_name=snap_name, endpoint_url=endpoint_url) - - -class GetRevisionGetInfoPage(BaseTestCases.EndpointLoggedInErrorHandling): - def setUp(self): - snap_name = "test-snap" - - api_url = ( - "https://dashboard.snapcraft.io/api/v2/snaps/{}" - + "/releases?page=1" - ) - api_url = api_url.format(snap_name) - endpoint_url = "/{}/releases".format(snap_name) - - super().setUp( - snap_name=snap_name, - endpoint_url=endpoint_url, - method_endpoint="GET", - api_url=api_url, - method_api="GET", - ) - - -class GetRevisionHistory(BaseTestCases.EndpointLoggedInErrorHandling): - def setUp(self): - snap_name = "test-snap" - - api_url = ( - "https://dashboard.snapcraft.io/api/v2/snaps/{}" - + "/releases?page=1" - ) - api_url = api_url.format(snap_name) - endpoint_url = "/{}/releases".format(snap_name) - - super().setUp( - snap_name=snap_name, - endpoint_url=endpoint_url, - api_url=api_url, - method_endpoint="GET", - method_api="GET", - ) - - @responses.activate - def test_get_revision(self): - info_url = "https://dashboard.snapcraft.io/api/v2/snaps/{}/channel-map" - self.info_url = info_url.format(self.snap_name) - - payload = { - "snap": { - "snap_id": "id", - "title": "Test Snap", - "publisher": {"display-name": "test"}, - }, - "channel-map": {}, - } - - responses.add(responses.GET, self.api_url, json={}, status=200) - - responses.add(responses.GET, self.info_url, json=payload, status=200) - - response = self.client.get(self.endpoint_url) - - self.assertEqual(2, len(responses.calls)) - called = responses.calls[0] - self.assertEqual(self.api_url, called.request.url) - self.assertEqual( - self.authorization, called.request.headers.get("Authorization") - ) - called = responses.calls[1] - self.assertEqual(self.info_url, called.request.url) - self.assertEqual( - self.authorization, called.request.headers.get("Authorization") - ) - self.assertEqual(response.status_code, 200) - self.assert_template_used("publisher/release-history.html") - self.assert_context("snap_name", self.snap_name) - self.assert_context("release_history", {}) - self.assert_context("channel_map", {}) diff --git a/webapp/publisher/snaps/release_views.py b/webapp/publisher/snaps/release_views.py index 44d4a109fd..f49ea8f0ce 100644 --- a/webapp/publisher/snaps/release_views.py +++ b/webapp/publisher/snaps/release_views.py @@ -18,7 +18,7 @@ def redirect_get_release_history(snap_name): @login_required -def get_release_history(snap_name): +def get_release_history_data(snap_name): release_history = publisher_api.snap_release_history( flask.session, snap_name ) @@ -33,12 +33,21 @@ def get_release_history(snap_name): "publisher_name": snap.get("publisher", {}).get("display-name", {}), "release_history": release_history, "private": snap.get("private"), - "default_track": snap.get("default-track"), + "default_track": ( + snap.get("default-track") + if snap.get("default-track") is not None + else "latest" + ), "channel_map": channel_map.get("channel-map"), "tracks": snap.get("tracks"), } - return flask.render_template("publisher/release-history.html", **context) + return flask.jsonify({"success": True, "data": context}) + + +@login_required +def get_releases(snap_name): + return flask.render_template("store/publisher.html") @login_required diff --git a/webapp/publisher/snaps/views.py b/webapp/publisher/snaps/views.py index f0234ecdab..93627aa43b 100644 --- a/webapp/publisher/snaps/views.py +++ b/webapp/publisher/snaps/views.py @@ -162,7 +162,12 @@ ) publisher_snaps.add_url_rule( "//releases", - view_func=release_views.get_release_history, + view_func=release_views.get_releases, + methods=["GET"], +) +publisher_snaps.add_url_rule( + "/api//releases", + view_func=release_views.get_release_history_data, methods=["GET"], ) publisher_snaps.add_url_rule( diff --git a/webpack.config.entry.js b/webpack.config.entry.js index edf1d75f05..c3e41a4f73 100644 --- a/webpack.config.entry.js +++ b/webpack.config.entry.js @@ -2,7 +2,6 @@ module.exports = { "cookie-policy": "./static/js/base/cookie-policy.ts", "global-nav": "./static/js/base/global-nav.ts", base: "./static/js/base/base.ts", - release: "./static/js/publisher/release.tsx", about: "./static/js/public/about/index.ts", "featured-snaps": "./static/js/public/featured-snaps.ts", modal: "./static/js/public/modal.ts", diff --git a/webpack.config.rules.js b/webpack.config.rules.js index 57712b2c04..b7e821a4ae 100644 --- a/webpack.config.rules.js +++ b/webpack.config.rules.js @@ -25,10 +25,6 @@ module.exports = [ test: require.resolve(__dirname + "/static/js/base/base.ts"), use: ["expose-loader?exposes=snapcraft.base", "babel-loader"], }, - { - test: require.resolve(__dirname + "/static/js/publisher/release.tsx"), - use: ["expose-loader?exposes=snapcraft.release", "babel-loader"], - }, { test: require.resolve(__dirname + "/static/js/publisher/publisher.ts"), use: ["expose-loader?exposes=snapcraft.publisher", "babel-loader"], From a303422e2e2176652913aea4aeb4e5b1326dac64 Mon Sep 17 00:00:00 2001 From: Steve Rydz Date: Wed, 20 Nov 2024 14:22:20 +0000 Subject: [PATCH 11/24] fix: Fix publicise disabled notification (#4911) --- static/js/global.d.ts | 6 - .../pages/Publicise/Publicise.tsx | 123 +++++++++++------- .../pages/Publicise/PubliciseBadges.tsx | 29 +++-- .../Publicise/__tests__/Publicise.test.tsx | 54 +++++--- templates/store/publisher.html | 7 - tests/publisher/snaps/tests_publicise.py | 81 ------------ .../publisher/snaps/tests_publicise_badges.py | 116 ----------------- .../publisher/snaps/tests_publicise_cards.py | 102 --------------- webapp/publisher/snaps/publicise_views.py | 39 ++---- webapp/publisher/snaps/views.py | 8 +- 10 files changed, 148 insertions(+), 417 deletions(-) delete mode 100644 tests/publisher/snaps/tests_publicise.py delete mode 100644 tests/publisher/snaps/tests_publicise_badges.py delete mode 100644 tests/publisher/snaps/tests_publicise_cards.py diff --git a/static/js/global.d.ts b/static/js/global.d.ts index 3ce309e21e..7a991a92a3 100644 --- a/static/js/global.d.ts +++ b/static/js/global.d.ts @@ -18,12 +18,6 @@ declare interface Window { 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; diff --git a/static/js/publisher-pages/pages/Publicise/Publicise.tsx b/static/js/publisher-pages/pages/Publicise/Publicise.tsx index 71c693dc6a..d03880b795 100644 --- a/static/js/publisher-pages/pages/Publicise/Publicise.tsx +++ b/static/js/publisher-pages/pages/Publicise/Publicise.tsx @@ -1,10 +1,12 @@ import { useParams, NavLink } from "react-router-dom"; +import { useQuery } from "react-query"; import { Row, Col, SideNavigation, Strip, Notification, + Icon, } from "@canonical/react-components"; import SectionNav from "../../components/SectionNav"; @@ -19,12 +21,27 @@ type Props = { function Publicise({ view }: Props): JSX.Element { const { snapId } = useParams(); + const { data, isLoading, isFetched } = useQuery({ + queryKey: ["publiciseData"], + queryFn: async () => { + const response = await fetch(`/api/${snapId}/publicise`); + + if (!response.ok) { + throw new Error("There was a problem loading publicise data"); + } + + const data = await response.json(); + + return data.data; + }, + }); + const disableView = () => { - if (window.SNAP_PUBLICISE_DATA.private) { + if (data.private) { return true; } - if (!window.SNAP_PUBLICISE_DATA.isReleased) { + if (!data.is_released) { return true; } @@ -40,50 +57,64 @@ function Publicise({ view }: Props): JSX.Element { - - {disableView() && ( - - When your snap is public and has a release, you'll be able to share - it using Store buttons, badges and embeddable cards. Make your snap - public in its settings page. - - )} - - - - - - {!view && } - {view === "badges" && } - {view === "cards" && } - - - + {isLoading && ( + +

+ +  Loading... +

+
+ )} + + {isFetched && data && ( + + {disableView() && ( + + When your snap is public and has a release, you'll be able to + share it using Store buttons, badges and embeddable cards. Make + your snap public in its{" "} + settings page. + + )} + + + + + + {!view && } + {view === "badges" && ( + + )} + {view === "cards" && } + + + + )} ); } diff --git a/static/js/publisher-pages/pages/Publicise/PubliciseBadges.tsx b/static/js/publisher-pages/pages/Publicise/PubliciseBadges.tsx index 7794dba5e8..b422d157a1 100644 --- a/static/js/publisher-pages/pages/Publicise/PubliciseBadges.tsx +++ b/static/js/publisher-pages/pages/Publicise/PubliciseBadges.tsx @@ -7,7 +7,11 @@ import { Notification, } from "@canonical/react-components"; -function PubliciseBadges(): JSX.Element { +type Props = { + trending: boolean; +}; + +function PubliciseBadges({ trending }: Props): JSX.Element { const { snapId } = useParams(); const [showStableChannelBadge, setShowStableChannelBadge] = useState(true); @@ -40,7 +44,9 @@ function PubliciseBadges(): JSX.Element { label="Stable channel from default track" checked={showStableChannelBadge} onChange={( - e: SyntheticEvent & { target: HTMLInputElement } + e: SyntheticEvent & { + target: HTMLInputElement; + }, ) => { setShowStableChannelBadge(e.target.checked); }} @@ -50,7 +56,9 @@ function PubliciseBadges(): JSX.Element { labelClassName="u-no-margin--bottom" checked={showTrendingStatusBadge} onChange={( - e: SyntheticEvent & { target: HTMLInputElement } + e: SyntheticEvent & { + target: HTMLInputElement; + }, ) => { setShowTrendingStatusBadge(e.target.checked); }} @@ -92,14 +100,13 @@ function PubliciseBadges(): JSX.Element { )}

- {!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. - - )} + {!trending && showTrendingStatusBadge && ( + + Your snap is not currently flagged as trending. Only when your + snap becomes trending will the trending badge appear on + external sites. + + )} diff --git a/static/js/publisher-pages/pages/Publicise/__tests__/Publicise.test.tsx b/static/js/publisher-pages/pages/Publicise/__tests__/Publicise.test.tsx index 0e738db01b..96a11d248d 100644 --- a/static/js/publisher-pages/pages/Publicise/__tests__/Publicise.test.tsx +++ b/static/js/publisher-pages/pages/Publicise/__tests__/Publicise.test.tsx @@ -1,68 +1,92 @@ import { BrowserRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider, useQuery } from "react-query"; import { screen, render } from "@testing-library/react"; import "@testing-library/jest-dom"; import Publicise from "../Publicise"; +const queryClient = new QueryClient(); + const renderComponent = (view?: "badges" | "cards" | undefined) => { return render( - - - + + + + + , + , ); }; -window.SNAP_PUBLICISE_DATA = { - hasScreenshot: true, - isReleased: true, +let mockPubliciseData = { + is_released: true, private: false, trending: false, }; +jest.mock("react-query", () => ({ + ...jest.requireActual("react-query"), + useQuery: jest.fn(), +})); + beforeEach(() => { - window.SNAP_PUBLICISE_DATA.private = false; + mockPubliciseData.private = false; }); describe("Publicise", () => { test("notification if private", () => { - window.SNAP_PUBLICISE_DATA.private = true; + // @ts-expect-error Mocking useQuery to return mock publicise data + useQuery.mockReturnValue({ + isLoading: false, + isFetched: true, + data: { data: mockPubliciseData }, + }); renderComponent(); expect(screen.getByText(/Make your snap public/)).toBeInTheDocument(); }); test("disabled if private", () => { - window.SNAP_PUBLICISE_DATA.private = true; + mockPubliciseData.private = true; + + // @ts-expect-error Mocking useQuery to return mock publicise data + useQuery.mockReturnValue({ + isLoading: false, + isFetched: true, + data: { data: mockPubliciseData }, + }); renderComponent(); - expect(document.querySelector(".u-disabled")).toBeInTheDocument(); + expect( + screen.getByText(/When your snap is public and has a release/), + ).toBeInTheDocument(); }); test("renders section navigation", () => { renderComponent(); expect( - screen.getByRole("link", { name: "Snap Store buttons" }) + screen.getByRole("link", { name: "Snap Store buttons" }), ).toBeInTheDocument(); expect( - screen.getByRole("link", { name: "GitHub badges" }) + screen.getByRole("link", { name: "GitHub badges" }), ).toBeInTheDocument(); expect( - screen.getByRole("link", { name: "Embeddable cards" }) + screen.getByRole("link", { name: "Embeddable cards" }), ).toBeInTheDocument(); }); test("renders buttons by default", () => { renderComponent(); expect( - screen.getByText(/You can help translate these buttons/) + screen.getByText(/You can help translate these buttons/), ).toBeInTheDocument(); }); test("renders badges if passed argument", () => { renderComponent("badges"); expect( - screen.getByText(/Stable channel from default track/) + screen.getByText(/Stable channel from default track/), ).toBeInTheDocument(); }); diff --git a/templates/store/publisher.html b/templates/store/publisher.html index 40d63e6288..8d090682e5 100644 --- a/templates/store/publisher.html +++ b/templates/store/publisher.html @@ -6,13 +6,6 @@ -{% endblock %} - {% block scripts %} {% if snaps %} -{% endblock %} - {% block scripts %} - {% if is_preview %} - - {% endif %} {% endblock %} {% block scripts %} @@ -374,7 +371,15 @@
Error
{% endif %} {% if is_preview %} - snapcraft.publisher.preview({{ package_name|tojson }}); + const editButton = document.querySelector('.js-edit'); + if (editButton) { + editButton.addEventListener("click", (e) => { + if (window.opener) { + e.preventDefault(); + window.close(); + } + }); + } {% endif %} }); }); diff --git a/templates/store/snap-details/_details.html b/templates/store/snap-details/_details.html index aa067e7171..2872db129a 100644 --- a/templates/store/snap-details/_details.html +++ b/templates/store/snap-details/_details.html @@ -24,9 +24,11 @@
Last updated
Websites
    {% for link in links["website"] %} + {% if format_link(link) != None %}
  • {{ format_link(link) }}
  • + {% endif %} {% endfor %}

@@ -36,9 +38,11 @@
Websites
Contact
    {% for link in links["contact"] %} + {% if format_link(link) != None %}
  • {{ format_link(link) }}
  • + {% endif %} {% endfor %}

@@ -48,9 +52,11 @@
Contact
Donations
    {% for link in links["donations"] %} + {% if format_link(link) != None %}
  • {{ format_link(link) }}
  • + {% endif %} {% endfor %}

@@ -60,9 +66,11 @@
Donations
Source code
    {% for link in links["source"] %} + {% if format_link(link) != None %}
  • {{ format_link(link) }}
  • + {% endif %} {% endfor %}

@@ -72,9 +80,11 @@
Source code
Report a bug
    {% for link in links["issues"] %} + {% if format_link(link) != None %}
  • {{ format_link(link) }}
  • + {% endif %} {% endfor %}

diff --git a/templates/store/snap-embedded-card.html b/templates/store/snap-embedded-card.html index 912a58187b..7221b8ec81 100644 --- a/templates/store/snap-embedded-card.html +++ b/templates/store/snap-embedded-card.html @@ -56,8 +56,10 @@

{{ snap_title }}

{{ summary }}

{% endif %} {% if button %} - Get it from the Snap Store + Get it from the Snap Store +
{% endif %}
diff --git a/webapp/markdown.py b/webapp/markdown.py index 3e96e371c0..723cdf1cbd 100644 --- a/webapp/markdown.py +++ b/webapp/markdown.py @@ -1,4 +1,5 @@ import re +import html from mistune import HTMLRenderer, Markdown from mistune.block_parser import ( @@ -59,4 +60,5 @@ class SnapcraftInlineParser(InlineParser): def parse_markdown_description(content): - return parser(content) + unescaped_content = html.unescape(content) + return parser(unescaped_content) diff --git a/webapp/publisher/snaps/publicise_views.py b/webapp/publisher/snaps/publicise_views.py index 8f1150090b..ac28a5a5b8 100644 --- a/webapp/publisher/snaps/publicise_views.py +++ b/webapp/publisher/snaps/publicise_views.py @@ -29,7 +29,6 @@ def get_publicise_data(snap_name): is_released = len(snap_details["channel_maps_list"]) > 0 - print(snap_details) context = { "is_released": is_released, "trending": trending, diff --git a/webpack.config.entry.js b/webpack.config.entry.js index c3e41a4f73..8908f6310f 100644 --- a/webpack.config.entry.js +++ b/webpack.config.entry.js @@ -5,10 +5,6 @@ module.exports = { about: "./static/js/public/about/index.ts", "featured-snaps": "./static/js/public/featured-snaps.ts", modal: "./static/js/public/modal.ts", - // TODO: - // publisher bundle is big (webpack warning) - try to chunk it down - // https://github.com/canonical-web-and-design/snapcraft.io/issues/1246 - publisher: "./static/js/publisher/publisher.ts", homepage: "./static/js/public/homepage.ts", blog: "./static/js/public/blog.ts", "store-details": "./static/js/public/store-details.ts", diff --git a/webpack.config.rules.js b/webpack.config.rules.js index b7e821a4ae..b7501966bd 100644 --- a/webpack.config.rules.js +++ b/webpack.config.rules.js @@ -25,14 +25,6 @@ module.exports = [ test: require.resolve(__dirname + "/static/js/base/base.ts"), use: ["expose-loader?exposes=snapcraft.base", "babel-loader"], }, - { - test: require.resolve(__dirname + "/static/js/publisher/publisher.ts"), - use: ["expose-loader?exposes=snapcraft.publisher", "babel-loader"], - }, - { - test: require.resolve(__dirname + "/static/js/public/about/index.ts"), - use: ["expose-loader?exposes=snapcraft.about", "babel-loader"], - }, { test: require.resolve(__dirname + "/static/js/public/featured-snaps.ts"), use: [