From 3b0f1b11a01e32c4f254861f66a28c14df70c890 Mon Sep 17 00:00:00 2001 From: Anurag Hazra Date: Thu, 30 Jul 2020 19:19:03 +0530 Subject: [PATCH] refactor: added reusable Card class to reduce code & test duplication (#260) * refactor: added reusable Card class to reduce code & test duplication * fix: top-langs card width & documented card_width option --- api/top-langs.js | 7 +- readme.md | 1 + src/Card.js | 139 +++++++++++++++++++++++++++++++ src/getStyles.js | 46 +++++----- src/renderRepoCard.js | 110 ++++++++++++------------ src/renderStatsCard.js | 103 +++++++++-------------- src/renderTopLanguages.js | 42 +++++----- tests/card.test.js | 136 ++++++++++++++++++++++++++++++ tests/renderStatsCard.test.js | 43 ---------- tests/renderTopLanguages.test.js | 19 ----- 10 files changed, 425 insertions(+), 221 deletions(-) create mode 100644 src/Card.js create mode 100644 tests/card.test.js diff --git a/api/top-langs.js b/api/top-langs.js index 840f88b..a758e33 100644 --- a/api/top-langs.js +++ b/api/top-langs.js @@ -14,13 +14,14 @@ module.exports = async (req, res) => { username, hide, hide_title, + hide_border, card_width, title_color, text_color, bg_color, theme, cache_seconds, - layout + layout, } = req.query; let topLangs; @@ -42,15 +43,15 @@ module.exports = async (req, res) => { res.send( renderTopLanguages(topLangs, { - theme, hide_title: parseBoolean(hide_title), + hide_border: parseBoolean(hide_border), card_width: parseInt(card_width, 10), hide: parseArray(hide), title_color, text_color, bg_color, theme, - layout + layout, }) ); }; diff --git a/readme.md b/readme.md index caaecf7..e4fc3ae 100644 --- a/readme.md +++ b/readme.md @@ -137,6 +137,7 @@ Customization Options: | cache_seconds | number | manually set custom cache control | 1800 | 1800 | 1800 | | count_private | boolean | counts private contributions too if enabled | false | N/A | N/A | | layout | string | choose a layout option | N/A | N/A | 'default' | +| card_width | number | set the card width | N/A | N/A | 300 | > Note on cache: Repo cards have default cache of 30mins (1800 seconds) if the fork count & star count is less than 1k otherwise it's 2hours (7200). Also note that cache is clamped to minimum of 30min and maximum of 24hours diff --git a/src/Card.js b/src/Card.js new file mode 100644 index 0000000..c8b21b6 --- /dev/null +++ b/src/Card.js @@ -0,0 +1,139 @@ +const { FlexLayout } = require("./utils"); +const { getAnimations } = require("./getStyles"); + +class Card { + constructor({ + width = 100, + height = 100, + colors = {}, + title = "", + titlePrefixIcon, + }) { + this.width = width; + this.height = height; + + this.hideBorder = false; + this.hideTitle = false; + + // returns theme based colors with proper overrides and defaults + this.colors = colors; + this.title = title; + this.css = ""; + + this.paddingX = 25; + this.paddingY = 35; + this.titlePrefixIcon = titlePrefixIcon; + this.animations = true; + } + + disableAnimations() { + this.animations = false; + } + + setCSS(value) { + this.css = value; + } + + setHideBorder(value) { + this.hideBorder = value; + } + + setHideTitle(value) { + this.hideTitle = value; + if (value) { + this.height -= 30; + } + } + + setTitle(text) { + this.title = text; + } + + renderTitle() { + const titleText = ` + ${this.title} + `; + + const prefixIcon = ` + + ${this.titlePrefixIcon} + + `; + return ` + + ${FlexLayout({ + items: [this.titlePrefixIcon && prefixIcon, titleText], + gap: 25, + }).join("")} + + `; + } + + render(body) { + return ` + + + + + + ${this.hideTitle ? "" : this.renderTitle()} + + + ${body} + + + `; + } +} + +module.exports = Card; diff --git a/src/getStyles.js b/src/getStyles.js index 1012624..b15f46d 100644 --- a/src/getStyles.js +++ b/src/getStyles.js @@ -9,10 +9,23 @@ const calculateCircleProgress = (value) => { return percentage; }; -const getAnimations = ({ progress }) => { +const getProgressAnimation = ({ progress }) => { + return ` + @keyframes rankAnimation { + from { + stroke-dashoffset: ${calculateCircleProgress(0)}; + } + to { + stroke-dashoffset: ${calculateCircleProgress(progress)}; + } + } + `; +}; + +const getAnimations = () => { return ` /* Animations */ - @keyframes scaleIn { + @keyframes scaleInAnimation { from { transform: translate(-5px, 5px) scale(0); } @@ -20,7 +33,7 @@ const getAnimations = ({ progress }) => { transform: translate(-5px, 5px) scale(1); } } - @keyframes fadeIn { + @keyframes fadeInAnimation { from { opacity: 0; } @@ -28,14 +41,6 @@ const getAnimations = ({ progress }) => { opacity: 1; } } - @keyframes rankAnimation { - from { - stroke-dashoffset: ${calculateCircleProgress(0)}; - } - to { - stroke-dashoffset: ${calculateCircleProgress(progress)}; - } - } `; }; @@ -47,20 +52,16 @@ const getStyles = ({ progress, }) => { return ` - .header { - font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${titleColor}; - animation: fadeIn 0.8s ease-in-out forwards; - } - .stat { + .stat { font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor}; } - .stagger { + .stagger { opacity: 0; - animation: fadeIn 0.3s ease-in-out forwards; + animation: fadeInAnimation 0.3s ease-in-out forwards; } - .rank-text { + .rank-text { font: 800 24px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor}; - animation: scaleIn 0.3s ease-in-out forwards; + animation: scaleInAnimation 0.3s ease-in-out forwards; } .bold { font-weight: 700 } @@ -86,9 +87,8 @@ const getStyles = ({ transform: rotate(-90deg); animation: rankAnimation 1s forwards ease-in-out; } - - ${process.env.NODE_ENV === "test" ? "" : getAnimations({ progress })} + ${process.env.NODE_ENV === "test" ? "" : getProgressAnimation({ progress })} `; }; -module.exports = getStyles; +module.exports = { getStyles, getAnimations }; diff --git a/src/renderRepoCard.js b/src/renderRepoCard.js index 8f9f5b6..9721ebf 100644 --- a/src/renderRepoCard.js +++ b/src/renderRepoCard.js @@ -7,6 +7,7 @@ const { } = require("../src/utils"); const icons = require("./icons"); const toEmoji = require("emoji-name-map"); +const Card = require("./Card"); const renderRepoCard = (repo, options = {}) => { const { @@ -84,68 +85,75 @@ const renderRepoCard = (repo, options = {}) => { ` : ""; + const iconWithLabel = (icon, label, testid) => { + return ` + + ${icon} + + ${label} + `; + }; const svgStars = stargazers.totalCount > 0 && - ` - - ${icons.star} - - ${totalStars} - `; - + iconWithLabel(icons.star, totalStars, "stargazers"); const svgForks = - forkCount > 0 && - ` - - ${icons.fork} - - ${totalForks} - `; + forkCount > 0 && iconWithLabel(icons.fork, totalForks, "forkcount"); - return ` - - + const starAndForkCount = FlexLayout({ + items: [svgStars, svgForks], + gap: 65, + }).join(""); - - - ${icons.contribs} - + const card = new Card({ + title: header, + titlePrefixIcon: icons.contribs, + width: 400, + height, + colors: { + titleColor, + textColor, + iconColor, + bgColor, + }, + }); - ${header} + card.disableAnimations(); + card.setHideBorder(false); + card.setHideTitle(false); + card.setCSS(` + .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } + .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } + .icon { fill: ${iconColor} } + .badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; } + .badge rect { opacity: 0.2 } + `); - ${ - isTemplate - ? getBadgeSVG("Template") - : isArchived - ? getBadgeSVG("Archived") - : "" - } + return card.render(` + ${ + isTemplate + ? getBadgeSVG("Template") + : isArchived + ? getBadgeSVG("Archived") + : "" + } - - ${multiLineDescription - .map((line) => `${encodeHTML(line)}`) - .join("")} - + + ${multiLineDescription + .map((line) => `${encodeHTML(line)}`) + .join("")} + - - ${svgLanguage} + + ${svgLanguage} - - ${FlexLayout({ items: [svgStars, svgForks], gap: 65 }).join("")} - + + ${starAndForkCount} - - `; + + `); }; module.exports = renderRepoCard; diff --git a/src/renderStatsCard.js b/src/renderStatsCard.js index 7e9d0cb..ace6bcb 100644 --- a/src/renderStatsCard.js +++ b/src/renderStatsCard.js @@ -4,8 +4,9 @@ const { FlexLayout, encodeHTML, } = require("../src/utils"); -const getStyles = require("./getStyles"); +const { getStyles } = require("./getStyles"); const icons = require("./icons"); +const Card = require("./Card"); const createTextNode = ({ icon, label, value, id, index, showIcons }) => { const kValue = kFormatter(value); @@ -52,7 +53,7 @@ const renderStatsCard = (stats = {}, options = { hide: [] }) => { theme = "default", } = options; - const lheight = parseInt(line_height); + const lheight = parseInt(line_height, 10); // returns theme based colors with proper overrides and defaults const { titleColor, textColor, iconColor, bgColor } = getCardColors({ @@ -116,44 +117,11 @@ const renderStatsCard = (stats = {}, options = { hide: [] }) => { hide_rank ? 0 : 150 ); - // the better user's score the the rank will be closer to zero so - // subtracting 100 to get the progress in 100% - const progress = 100 - rank.score; - - const styles = getStyles({ - titleColor, - textColor, - iconColor, - show_icons, - progress, - }); - // Conditionally rendered elements - - const apostrophe = ["x", "s"].includes(name.slice(-1)) ? "" : "s"; - const title = hide_title - ? "" - : `${encodeHTML(name)}'${apostrophe} GitHub Stats`; - - const border = ` - - `; - const rankCircle = hide_rank ? "" - : ` + : ` @@ -169,34 +137,45 @@ const renderStatsCard = (stats = {}, options = { hide: [] }) => { `; - if (hide_title) { - height -= 30; - } + // the better user's score the the rank will be closer to zero so + // subtracting 100 to get the progress in 100% + const progress = 100 - rank.score; + const cssStyles = getStyles({ + titleColor, + textColor, + iconColor, + show_icons, + progress, + }); - return ` - - - - ${border} - ${title} + const apostrophe = ["x", "s"].includes(name.slice(-1)) ? "" : "s"; + const card = new Card({ + title: `${encodeHTML(name)}'${apostrophe} GitHub Stats`, + width: 495, + height, + colors: { + titleColor, + textColor, + iconColor, + bgColor, + }, + }); - - ${rankCircle} + card.setHideBorder(hide_border); + card.setHideTitle(hide_title); + card.setCSS(cssStyles); - - ${FlexLayout({ - items: statItems, - gap: lheight, - direction: "column", - }).join("")} - - - - `; + return card.render(` + ${rankCircle} + + + ${FlexLayout({ + items: statItems, + gap: lheight, + direction: "column", + }).join("")} + + `); }; module.exports = renderStatsCard; diff --git a/src/renderTopLanguages.js b/src/renderTopLanguages.js index 21594df..5ccb2e0 100644 --- a/src/renderTopLanguages.js +++ b/src/renderTopLanguages.js @@ -1,4 +1,5 @@ const { getCardColors, FlexLayout, clampValue } = require("../src/utils"); +const Card = require("./Card"); const createProgressNode = ({ width, color, name, progress }) => { const paddingRight = 95; @@ -63,6 +64,7 @@ const lowercaseTrim = (name) => name.toLowerCase().trim(); const renderTopLanguages = (topLangs, options = {}) => { const { hide_title, + hide_border, card_width, title_color, text_color, @@ -170,29 +172,29 @@ const renderTopLanguages = (topLangs, options = {}) => { }).join(""); } - if (hide_title) { - height -= 30; - } + const card = new Card({ + title: "Most Used Languages", + width, + height, + colors: { + titleColor, + textColor, + bgColor, + }, + }); - return ` - - - + card.disableAnimations(); + card.setHideBorder(hide_border); + card.setHideTitle(hide_title); + card.setCSS(` + .lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } + `); - ${ - hide_title - ? "" - : `Most Used Languages` - } - - - ${finalLayout} - + return card.render(` + + ${finalLayout} - `; + `); }; module.exports = renderTopLanguages; diff --git a/tests/card.test.js b/tests/card.test.js new file mode 100644 index 0000000..318cf56 --- /dev/null +++ b/tests/card.test.js @@ -0,0 +1,136 @@ +require("@testing-library/jest-dom"); +const cssToObject = require("css-to-object"); +const Card = require("../src/Card"); +const icons = require("../src/icons"); +const { getCardColors } = require("../src/utils"); +const { queryByTestId } = require("@testing-library/dom"); + +describe("Card", () => { + it("should hide border", () => { + const card = new Card({}); + card.setHideBorder(true); + + document.body.innerHTML = card.render(``); + expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( + "stroke-opacity", + "0" + ); + }); + + it("should not hide border", () => { + const card = new Card({}); + card.setHideBorder(false); + + document.body.innerHTML = card.render(``); + expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( + "stroke-opacity", + "1" + ); + }); + + it("should hide title", () => { + const card = new Card({}); + card.setHideTitle(true); + + document.body.innerHTML = card.render(``); + expect(queryByTestId(document.body, "card-title")).toBeNull(); + }); + + it("should not hide title", () => { + const card = new Card({}); + card.setHideTitle(false); + + document.body.innerHTML = card.render(``); + expect(queryByTestId(document.body, "card-title")).toBeInTheDocument(); + }); + + it("title should have prefix icon", () => { + const card = new Card({ title: "ok", titlePrefixIcon: icons.contribs }); + + document.body.innerHTML = card.render(``); + expect(document.getElementsByClassName("icon")[0]).toBeInTheDocument(); + }); + + it("title should not have prefix icon", () => { + const card = new Card({ title: "ok" }); + + document.body.innerHTML = card.render(``); + expect(document.getElementsByClassName("icon")[0]).toBeUndefined(); + }); + + it("should have proper height, width", () => { + const card = new Card({ height: 200, width: 200, title: "ok" }); + document.body.innerHTML = card.render(``); + expect(document.getElementsByTagName("svg")[0]).toHaveAttribute( + "height", + "200" + ); + expect(document.getElementsByTagName("svg")[0]).toHaveAttribute( + "height", + "200" + ); + }); + + it("should have less height after title is hidden", () => { + const card = new Card({ height: 200, title: "ok" }); + card.setHideTitle(true); + + document.body.innerHTML = card.render(``); + expect(document.getElementsByTagName("svg")[0]).toHaveAttribute( + "height", + "170" + ); + }); + + it("main-card-body should have proper when title is visible", () => { + const card = new Card({ height: 200 }); + document.body.innerHTML = card.render(``); + expect(queryByTestId(document.body, "main-card-body")).toHaveAttribute( + "transform", + "translate(0, 55)" + ); + }); + + it("main-card-body should have proper position after title is hidden", () => { + const card = new Card({ height: 200 }); + card.setHideTitle(true); + + document.body.innerHTML = card.render(``); + expect(queryByTestId(document.body, "main-card-body")).toHaveAttribute( + "transform", + "translate(0, 25)" + ); + }); + + it("should render with correct colors", () => { + // returns theme based colors with proper overrides and defaults + const { titleColor, textColor, iconColor, bgColor } = getCardColors({ + title_color: "f00", + icon_color: "0f0", + text_color: "00f", + bg_color: "fff", + theme: "default", + }); + + const card = new Card({ + height: 200, + colors: { + titleColor, + textColor, + iconColor, + bgColor, + }, + }); + document.body.innerHTML = card.render(``); + + const styleTag = document.querySelector("style"); + const stylesObject = cssToObject(styleTag.innerHTML); + const headerClassStyles = stylesObject[".header"]; + + expect(headerClassStyles.fill).toBe("#f00"); + expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( + "fill", + "#fff" + ); + }); +}); diff --git a/tests/renderStatsCard.test.js b/tests/renderStatsCard.test.js index 04cdadb..983a406 100644 --- a/tests/renderStatsCard.test.js +++ b/tests/renderStatsCard.test.js @@ -69,20 +69,6 @@ describe("Test renderStatsCard", () => { expect(queryByTestId(document.body, "contribs")).toBeNull(); }); - it("should hide_border", () => { - document.body.innerHTML = renderStatsCard(stats, { hide_border: true }); - expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( - "stroke-opacity", - "0" - ); - - document.body.innerHTML = renderStatsCard(stats, { hide_border: false }); - expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( - "stroke-opacity", - "1" - ); - }); - it("should hide_rank", () => { document.body.innerHTML = renderStatsCard(stats, { hide_rank: true }); @@ -202,35 +188,6 @@ describe("Test renderStatsCard", () => { ); }); - it("should hide the title", () => { - document.body.innerHTML = renderStatsCard(stats, { - hide_title: true, - }); - - expect(document.getElementsByClassName("header")[0]).toBeUndefined(); - expect(document.getElementsByTagName("svg")[0]).toHaveAttribute( - "height", - "165" - ); - expect(queryByTestId(document.body, "card-body-content")).toHaveAttribute( - "transform", - "translate(0, -30)" - ); - }); - - it("should not hide the title", () => { - document.body.innerHTML = renderStatsCard(stats, {}); - - expect(document.getElementsByClassName("header")[0]).toBeDefined(); - expect(document.getElementsByTagName("svg")[0]).toHaveAttribute( - "height", - "195" - ); - expect(queryByTestId(document.body, "card-body-content")).toHaveAttribute( - "transform", - "translate(0, 0)" - ); - }); it("should render icons correctly", () => { document.body.innerHTML = renderStatsCard(stats, { diff --git a/tests/renderTopLanguages.test.js b/tests/renderTopLanguages.test.js index a6f94a8..9996038 100644 --- a/tests/renderTopLanguages.test.js +++ b/tests/renderTopLanguages.test.js @@ -98,25 +98,6 @@ describe("Test renderTopLanguages", () => { expect(document.querySelector("svg")).toHaveAttribute("height", "245"); }); - it("should hide_title", () => { - document.body.innerHTML = renderTopLanguages(langs, { hide_title: false }); - expect(document.querySelector("svg")).toHaveAttribute("height", "205"); - expect(queryByTestId(document.body, "lang-items")).toHaveAttribute( - "y", - "55" - ); - - // Lets hide now - document.body.innerHTML = renderTopLanguages(langs, { hide_title: true }); - expect(document.querySelector("svg")).toHaveAttribute("height", "175"); - - expect(queryByTestId(document.body, "header")).not.toBeInTheDocument(); - expect(queryByTestId(document.body, "lang-items")).toHaveAttribute( - "y", - "25" - ); - }); - it("should render with custom width set", () => { document.body.innerHTML = renderTopLanguages(langs, {});