From 9dfd31da1031e280a5c9899150e7e5a8aaff8152 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 28 Jan 2020 11:30:04 -0800 Subject: [PATCH] add os download page --- app/controllers/dashboard_controller.rb | 2 + app/views/dashboard/os_download.html.erb | 1 + config/routes.rb | 1 + frontend/__tests__/routes_test.tsx | 7 ++ frontend/constants.ts | 5 + frontend/css/global.scss | 69 +++++++++++ frontend/devices/interfaces.ts | 4 + .../os_download/__tests__/content_test.tsx | 53 +++++++++ frontend/os_download/__tests__/index_test.ts | 16 +++ frontend/os_download/content.tsx | 108 ++++++++++++++++++ frontend/os_download/index.tsx | 16 +++ spec/controllers/dashboard_spec.rb | 5 + 12 files changed, 287 insertions(+) create mode 100644 app/views/dashboard/os_download.html.erb create mode 100644 frontend/os_download/__tests__/content_test.tsx create mode 100644 frontend/os_download/__tests__/index_test.ts create mode 100644 frontend/os_download/content.tsx create mode 100644 frontend/os_download/index.tsx diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index e188a75a9..090fd4069 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -7,6 +7,7 @@ class DashboardController < ApplicationController :main_app, :password_reset, :tos_update, + :os_download, :demo] OUTPUT_URL = "/" + File.join("assets", "parcel") # <= served from public/ dir @@ -24,6 +25,7 @@ class DashboardController < ApplicationController password_reset: "/password_reset/index.tsx", tos_update: "/tos_update/index.tsx", demo: "/demo/index.tsx", + os_download: "/os_download/index.tsx" }.with_indifferent_access # === THESE CONSTANTS ARE NON-CONFIGURABLE. === diff --git a/app/views/dashboard/os_download.html.erb b/app/views/dashboard/os_download.html.erb new file mode 100644 index 000000000..4d5af9468 --- /dev/null +++ b/app/views/dashboard/os_download.html.erb @@ -0,0 +1 @@ +<%# Intentionally blank template required by router for content generated by frontend. %> diff --git a/config/routes.rb b/config/routes.rb index 766c0d44b..1c8717c8d 100755 --- a/config/routes.rb +++ b/config/routes.rb @@ -115,6 +115,7 @@ FarmBot::Application.routes.draw do match "/app/*path", to: "dashboard#main_app", via: :all, constraints: { format: "html" } get "/demo" => "dashboard#demo", as: :demo_main + get "/os" => "dashboard#os_download", as: :os_download get "/password_reset/*token" => "dashboard#password_reset", as: :password_reset get "/tos_update" => "dashboard#tos_update", as: :tos_update get "/verify/:token" => "dashboard#confirmation_page", as: :confirmation_page diff --git a/frontend/__tests__/routes_test.tsx b/frontend/__tests__/routes_test.tsx index b726f399b..a7eda60ae 100644 --- a/frontend/__tests__/routes_test.tsx +++ b/frontend/__tests__/routes_test.tsx @@ -21,6 +21,7 @@ import { store } from "../redux/store"; import { AuthState } from "../auth/interfaces"; import { auth } from "../__test_support__/fake_state/token"; import { Session } from "../session"; +import { FourOhFour } from "../404"; describe("", () => { it("clears session when not authorized", () => { @@ -38,4 +39,10 @@ describe("", () => { expect(Session.clear).not.toHaveBeenCalled(); wrapper.unmount(); }); + + it("changes route", () => { + const wrapper = shallow(); + wrapper.instance().changeRoute(FourOhFour); + expect(wrapper.state().Route).toEqual(FourOhFour); + }); }); diff --git a/frontend/constants.ts b/frontend/constants.ts index 5dc181ec3..6f4cc2429 100644 --- a/frontend/constants.ts +++ b/frontend/constants.ts @@ -837,6 +837,11 @@ export namespace Content { export const NO_CAMERA_SELECTED = trim(`No camera selected`); + + // Other + export const DOWNLOAD_FBOS = + trim(`Download the version of FarmBot OS that corresponds to your + FarmBot kit and its internal computer.`); } export namespace TourContent { diff --git a/frontend/css/global.scss b/frontend/css/global.scss index b5f56dd1e..4ed67c23c 100644 --- a/frontend/css/global.scss +++ b/frontend/css/global.scss @@ -1528,3 +1528,72 @@ textarea:focus { background: darken(lightgray, 10%); } } + +.transparent-link-button { + font-size: 1rem; + border: 1px solid; + padding: 0.4rem 1.2rem; + font-weight: bold; + letter-spacing: 1px; + border-radius: 4px; + color: $off_white; + &:hover { + color: $white; + } +} + +.os-download-page { + text-align: center; + .all-content-wrapper { + padding-top: 0 !important; + min-height: 30rem; + } + h1 { + margin-top: 5rem; + font-size: 2rem !important; + text-shadow: 0 0 5px rgba(0, 0, 0, 0.1), 0 0 25px rgba(0, 0, 0, 0.1) !important; + } + p { + margin: auto; + width: 70%; + color: $off_white; + } + a { + white-space: nowrap; + font-weight: bold !important; + color: $off_white !important; + &:hover { + color: $white !important; + } + &:visited { + color: $off_white; + } + &:link { + color: $off_white; + } + &:active { + color: $dark_gray !important; + } + } + table { + margin: 2rem; + margin-top: 3rem; + width: 93%; + font-size: 1.2rem; + color: $off_white; + text-align: left; + thead { + border-bottom: 2px solid $off_white; + } + tr:not(:last-child) { + border-bottom: 1px solid $gray; + } + td { + padding-top: 1rem; + padding-bottom: 1rem; + span { + display: block; + } + } + } +} diff --git a/frontend/devices/interfaces.ts b/frontend/devices/interfaces.ts index 9cd5d4174..23a95a6cf 100644 --- a/frontend/devices/interfaces.ts +++ b/frontend/devices/interfaces.ts @@ -133,6 +133,10 @@ export type HardwareState = BotStateTree; export interface GithubRelease { tag_name: string; target_commitish: string; + assets: { + name: string; + browser_download_url: string; + }[]; } export interface OsUpdateInfo { diff --git a/frontend/os_download/__tests__/content_test.tsx b/frontend/os_download/__tests__/content_test.tsx new file mode 100644 index 000000000..b40180e2a --- /dev/null +++ b/frontend/os_download/__tests__/content_test.tsx @@ -0,0 +1,53 @@ +const mockRelease = { + tag_name: "v1.0.0", + assets: [ + { name: "farmbot-rpi-1.0.0.fw", browser_download_url: "fake rpi fw url" }, + { name: "farmbot-rpi-1.0.0.img", browser_download_url: "fake rpi img url" }, + { name: "farmbot-rpi3-1.0.0.img", browser_download_url: "fake rpi3 img url" }, + ] +}; +let mockResponse = Promise.resolve({ data: mockRelease }); +jest.mock("axios", () => ({ get: jest.fn(() => mockResponse) })); + +import * as React from "react"; +import { mount } from "enzyme"; +import { OsDownload } from "../content"; + +describe("", () => { + it("fetches and renders", async () => { + const wrapper = await mount(); + expect(wrapper.state().tagName).toEqual("v1.0.0"); + expect(wrapper.state().genesisImg).toEqual("fake rpi3 img url"); + expect(wrapper.text()).toContain("Download FBOS v1.0.0"); + wrapper.update(); + expect(wrapper.find("a").first().props().href) + .toEqual("fake rpi3 img url"); + }); + + it("uses fallback", async () => { + globalConfig.GENESIS_IMG_FALLBACK = "fake rpi3 img fallback url///////v0.0.0"; + mockResponse = Promise.reject(); + const wrapper = await mount(); + expect(wrapper.text()).toContain("Download FBOS v0.0.0"); + expect(wrapper.find("a").first().props().href) + .toEqual(globalConfig.GENESIS_IMG_FALLBACK); + }); + + it("uses override", async () => { + globalConfig.GENESIS_IMG_OVERRIDE = "fake rpi3 img override url"; + const wrapper = await mount(); + expect(wrapper.text()).toContain("Download FBOS"); + wrapper.update(); + expect(wrapper.find("a").first().props().href) + .toEqual(globalConfig.GENESIS_IMG_OVERRIDE); + }); + + it("handles missing data", async () => { + delete globalConfig.GENESIS_IMG_OVERRIDE; + delete mockRelease.assets; + const wrapper = await mount(); + wrapper.update(); + expect(wrapper.find("a").first().props().href) + .toEqual(globalConfig.GENESIS_IMG_FALLBACK); + }); +}); diff --git a/frontend/os_download/__tests__/index_test.ts b/frontend/os_download/__tests__/index_test.ts new file mode 100644 index 000000000..c1e3788da --- /dev/null +++ b/frontend/os_download/__tests__/index_test.ts @@ -0,0 +1,16 @@ +jest.mock("i18next", () => ({ init: jest.fn((_, ok) => ok()) })); +jest.mock("react-dom", () => ({ render: jest.fn() })); +jest.mock("../../i18n", + () => ({ detectLanguage: jest.fn(() => Promise.resolve()) })); + +import { detectLanguage } from "../../i18n"; +import { render } from "react-dom"; + +describe("index.ts", () => { + it("attaches the os download page to the DOM", async () => { + await import("../index"); + expect(detectLanguage).toHaveBeenCalled(); + expect(document.getElementById("root")).toBeTruthy(); + expect(render).toHaveBeenCalled(); + }); +}); diff --git a/frontend/os_download/content.tsx b/frontend/os_download/content.tsx new file mode 100644 index 000000000..a55436f1e --- /dev/null +++ b/frontend/os_download/content.tsx @@ -0,0 +1,108 @@ +import * as React from "react"; +import axios from "axios"; +import { t } from "../i18next_wrapper"; +import { GithubRelease } from "../devices/interfaces"; +import { Content } from "../constants"; + +const LATEST_RELEASE_URL = + "https://api.github.com/repos/farmbot/farmbot_os/releases/latest"; + +interface OsDownloadState { + tagName: string; + genesisImg: string; + expressImg: string; +} + +const getImgLink = (assets: GithubRelease["assets"], target: string) => + assets.filter(asset => asset.name.includes("img") + && asset.name.includes(target))[0]?.browser_download_url || ""; + +const tagNameFromUrl = (url: string) => { + const tagPart = url.split("/")[7] || ""; + return tagPart.startsWith("v") ? tagPart : ""; +}; + +export class OsDownload extends React.Component<{}, OsDownloadState> { + state: OsDownloadState = { tagName: "", genesisImg: "", expressImg: "" }; + + get genesisTagName() { + return this.state.tagName || tagNameFromUrl(this.genesisImgDownloadLink); + } + + get expressTagName() { + return this.state.tagName || tagNameFromUrl(this.expressImgDownloadLink); + } + + get genesisImgDownloadLink() { + return globalConfig.GENESIS_IMG_OVERRIDE || + this.state.genesisImg || + globalConfig.GENESIS_IMG_FALLBACK || ""; + } + + get expressImgDownloadLink() { + return globalConfig.EXPRESS_IMG_OVERRIDE || + this.state.expressImg || + globalConfig.EXPRESS_IMG_FALLBACK || ""; + } + + fetchLatestRelease = () => + axios.get(LATEST_RELEASE_URL) + .then(resp => + this.setState({ + tagName: resp.data.tag_name, + genesisImg: getImgLink(resp.data.assets, "rpi3"), + expressImg: getImgLink(resp.data.assets, "rpi-"), + })).catch(() => { }); + + componentDidMount() { this.fetchLatestRelease(); } + + render() { + return
+
+

{t("Download FarmBot OS")}

+

{t(Content.DOWNLOAD_FBOS)}

+ + + + + + + + + + + + + + + + + + + + +
{t("FarmBot Kit")}{t("Internal Computer")}{t("Download Link")}
+ {"Genesis v1.2"} + {"Genesis v1.3"} + {"Genesis v1.4"} + {"Genesis XL v1.4"} + {"Genesis v1.5"} + {"Genesis XL v1.5"} + {t("Raspberry Pi 3")} + + {`${t("Download")} FBOS ${this.genesisTagName}`} + +
+ {"Express v1.0"} + {"Express XL v1.0"} + {t("Raspberry Pi Zero W")} + + {`${t("Download")} FBOS ${this.expressTagName}`} + +
+
+
; + } +} diff --git a/frontend/os_download/index.tsx b/frontend/os_download/index.tsx new file mode 100644 index 000000000..1321706bb --- /dev/null +++ b/frontend/os_download/index.tsx @@ -0,0 +1,16 @@ +import { render } from "react-dom"; +import I from "i18next"; +import { detectLanguage } from "../i18n"; +import * as _React from "react"; +import { createElement } from "react"; +import { OsDownload } from "./content"; + +const node = document.createElement("DIV"); +node.id = "root"; +document.body.appendChild(node); +const domElem = document.getElementById("root"); +const reactElem = createElement(OsDownload, {}); + +const ok = () => domElem && render(reactElem, domElem); + +detectLanguage().then(conf => I.init(conf, ok)); diff --git a/spec/controllers/dashboard_spec.rb b/spec/controllers/dashboard_spec.rb index 5323fdb76..2908d9a26 100644 --- a/spec/controllers/dashboard_spec.rb +++ b/spec/controllers/dashboard_spec.rb @@ -10,6 +10,11 @@ describe DashboardController do expect(response.status).to eq(200) end + it "renders the os download page" do + get :os_download + expect(response.status).to eq(200) + end + it "renders the front page" do get :front_page expect(response.status).to eq(200)