diff --git a/api/pin.js b/api/pin.js index bede7d87f5972..5595394a5b460 100644 --- a/api/pin.js +++ b/api/pin.js @@ -5,6 +5,7 @@ import { CONSTANTS, parseBoolean, renderError, + parseArray, } from "../src/common/utils.js"; import { fetchRepo } from "../src/fetchers/repo-fetcher.js"; import { isLocaleAvailable } from "../src/translations.js"; @@ -25,6 +26,10 @@ export default async (req, res) => { border_radius, border_color, description_lines_count, + show_lang_bar, + hide, + langs_count, + hide_progress, } = req.query; res.setHeader("Content-Type", "image/svg+xml"); @@ -83,6 +88,10 @@ export default async (req, res) => { show_owner: parseBoolean(show_owner), locale: locale ? locale.toLowerCase() : null, description_lines_count, + show_lang_bar: parseBoolean(show_lang_bar), + hide: parseArray(hide), + langs_count, + hide_progress: parseBoolean(hide_progress), }), ); } catch (err) { diff --git a/src/cards/repo-card.js b/src/cards/repo-card.js index bbfda52d47778..074341a892b5a 100644 --- a/src/cards/repo-card.js +++ b/src/cards/repo-card.js @@ -15,11 +15,16 @@ import { clampValue, } from "../common/utils.js"; import { repoCardLocales } from "../translations.js"; +import { + renderCompactLayout, + trimTopLanguages, + getDefaultLanguagesCountByLayout, +} from "./top-languages-card.js"; const ICON_SIZE = 16; const DESCRIPTION_LINE_WIDTH = 59; const DESCRIPTION_MAX_LINES = 3; - +const CARD_PADDING = 25; /** * Retrieves the repository description and wraps it to fit the card width. * @@ -42,6 +47,18 @@ const getBadgeSVG = (label, textColor) => ` `; +/** + * Calculates extra height needed for the languages bar. + * + * @param {number} totalLangs Total number of languages. + * @param {boolean} hideProgress Flag to hide progress bar. + * @returns {number} Card height. + */ +const calculateExtraLangHeigth = (totalLangs, hideProgress) => { + const baseHeight = Math.ceil(totalLangs / 2) * 25; + return hideProgress ? baseHeight - 30 : baseHeight; +}; + /** * @typedef {import("../fetchers/types").RepositoryData} RepositoryData Repository data. * @typedef {import("./types").RepoCardOptions} RepoCardOptions Repo card options. @@ -64,6 +81,7 @@ const renderRepoCard = (repo, options = {}) => { isTemplate, starCount, forkCount, + languagesBreakdown, } = repo; const { hide_border = false, @@ -77,8 +95,19 @@ const renderRepoCard = (repo, options = {}) => { border_color, locale, description_lines_count, + show_lang_bar = false, + hide_progress = false, + layout = "compact", // add more layouts in the future + langs_count = getDefaultLanguagesCountByLayout({ layout, hide_progress }), + hide, } = options; + const { langs, totalLanguageSize } = trimTopLanguages( + languagesBreakdown, + langs_count, + hide, + ); + const lineHeight = 10; const header = show_owner ? nameWithOwner : name; const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified"; @@ -149,11 +178,22 @@ const renderRepoCard = (repo, options = {}) => { gap: 25, }).join(""); + const languageBar = renderCompactLayout( + langs, + 400, + totalLanguageSize, + hide_progress, + ); + const card = new Card({ defaultTitle: header.length > 35 ? `${header.slice(0, 35)}...` : header, titlePrefixIcon: icons.contribs, width: 400, - height, + height: + height + + (show_lang_bar + ? calculateExtraLangHeigth(langs.length, hide_progress) + : 0), border_radius, colors, }); @@ -167,8 +207,32 @@ const renderRepoCard = (repo, options = {}) => { .icon { fill: ${colors.iconColor} } .badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; } .badge rect { opacity: 0.2 } + .lang-name { font: 400 11px "Segoe UI", Ubuntu, Sans-Serif; fill: ${colors.textColor} } `); + if (show_lang_bar) { + return card.render(` + ${ + isTemplate + ? // @ts-ignore + getBadgeSVG(i18n.t("repocard.template"), colors.textColor) + : isArchived + ? // @ts-ignore + getBadgeSVG(i18n.t("repocard.archived"), colors.textColor) + : "" + } + + + ${descriptionSvg} + + + + ${languageBar} + + + `); + } + return card.render(` ${ isTemplate @@ -187,6 +251,7 @@ const renderRepoCard = (repo, options = {}) => { ${starAndForkCount} + `); }; diff --git a/src/cards/top-languages-card.js b/src/cards/top-languages-card.js index 9385f4a7ebed3..749bffd290ab7 100644 --- a/src/cards/top-languages-card.js +++ b/src/cards/top-languages-card.js @@ -871,6 +871,7 @@ const renderTopLanguages = (topLangs, options = {}) => { }; export { + renderCompactLayout, getLongestLang, degreesToRadians, radiansToDegrees, diff --git a/src/cards/types.d.ts b/src/cards/types.d.ts index 9a21be4a0160a..5e47a74cf0702 100644 --- a/src/cards/types.d.ts +++ b/src/cards/types.d.ts @@ -33,6 +33,11 @@ export type StatCardOptions = CommonOptions & { export type RepoCardOptions = CommonOptions & { show_owner: boolean; description_lines_count: number; + show_lang_bar: boolean; + hide_progress: boolean; + layout: "compact"; + langs_count: number; + hide: string[]; }; export type TopLangOptions = CommonOptions & { diff --git a/src/fetchers/repo-fetcher.js b/src/fetchers/repo-fetcher.js index 6438f8895cfb6..5acb72cc1d401 100644 --- a/src/fetchers/repo-fetcher.js +++ b/src/fetchers/repo-fetcher.js @@ -55,6 +55,38 @@ const fetcher = (variables, token) => { }, ); }; +/** + * Language data fetcher. + * + * @param {AxiosRequestHeaders} variables Fetcher variables. + * @param {string} token GitHub token. + * @returns {Promise} The response. + */ +const fetcherLanguage = (variables, token) => { + return request( + { + query: ` + query getRepoLanguages($login: String!, $repo: String!) { + repository(owner: $login, name: $repo) { + languages(first: 10) { + edges { + node { + name + color + } + size + } + } + } + } + `, + variables, + }, + { + Authorization: `token ${token}`, + }, + ); +}; const urlExample = "/api/pin?username=USERNAME&repo=REPO_NAME"; @@ -91,6 +123,30 @@ const fetchRepo = async (username, reponame) => { const isUser = data.organization === null && data.user; const isOrg = data.user === null && data.organization; + let resLanguage = await retryer(fetcherLanguage, { + login: username, + repo: reponame, + }); + + const data_languages = resLanguage.data.data; + + const toplanguages = data_languages.repository.languages.edges.reduce( + (acc, edge) => { + const { name, color } = edge.node; + const size = edge.size; + + if (acc[name]) { + acc[name].size += size; + acc[name].count += 1; + } else { + acc[name] = { name, color, size, count: 1 }; + } + + return acc; + }, + {}, + ); + if (isUser) { if (!data.user.repository || data.user.repository.isPrivate) { throw new Error("User Repository Not found"); @@ -98,6 +154,7 @@ const fetchRepo = async (username, reponame) => { return { ...data.user.repository, starCount: data.user.repository.stargazers.totalCount, + languagesBreakdown: toplanguages, }; } @@ -111,6 +168,7 @@ const fetchRepo = async (username, reponame) => { return { ...data.organization.repository, starCount: data.organization.repository.stargazers.totalCount, + languagesBreakdown: toplanguages, }; } diff --git a/src/fetchers/types.d.ts b/src/fetchers/types.d.ts index affb407b816b0..31dc927c7e793 100644 --- a/src/fetchers/types.d.ts +++ b/src/fetchers/types.d.ts @@ -20,6 +20,7 @@ export type RepositoryData = { id: string; name: string; }; + languagesBreakdown: TopLangData; forkCount: number; starCount: number; }; diff --git a/tests/fetchRepo.test.js b/tests/fetchRepo.test.js index a980917f3d628..44e762923c452 100644 --- a/tests/fetchRepo.test.js +++ b/tests/fetchRepo.test.js @@ -18,6 +18,29 @@ const data_repo = { }, }; +const data_languages = { + languagesBreakdown: { + HTML: { + color: "#0f0", + name: "HTML", + size: 200, + count: 1, + }, + javascript: { + color: "#0ff", + name: "javascript", + size: 200, + count: 1, + }, + css: { + color: "#ff0", + name: "css", + size: 100, + count: 1, + }, + }, +}; + const data_user = { data: { user: { repository: data_repo.repository }, @@ -32,6 +55,19 @@ const data_org = { }, }; +const data_languages_resp = { + data: { + repository: { + languages: { + edges: [ + { node: { name: "HTML", color: "#0f0" }, size: 200 }, + { node: { name: "javascript", color: "#0ff" }, size: 200 }, + { node: { name: "css", color: "#ff0" }, size: 100 }, + ], + }, + }, + }, +}; const mock = new MockAdapter(axios); afterEach(() => { @@ -40,30 +76,42 @@ afterEach(() => { describe("Test fetchRepo", () => { it("should fetch correct user repo", async () => { - mock.onPost("https://api.github.com/graphql").reply(200, data_user); + mock.onPost("https://api.github.com/graphql").replyOnce(200, data_user); + mock + .onPost("https://api.github.com/graphql") + .replyOnce(200, data_languages_resp); let repo = await fetchRepo("anuraghazra", "convoychat"); + console.log(repo); expect(repo).toStrictEqual({ ...data_repo.repository, + languagesBreakdown: data_languages.languagesBreakdown, starCount: data_repo.repository.stargazers.totalCount, }); }); it("should fetch correct org repo", async () => { - mock.onPost("https://api.github.com/graphql").reply(200, data_org); + mock.onPost("https://api.github.com/graphql").replyOnce(200, data_org); + mock + .onPost("https://api.github.com/graphql") + .replyOnce(200, data_languages_resp); let repo = await fetchRepo("anuraghazra", "convoychat"); expect(repo).toStrictEqual({ ...data_repo.repository, starCount: data_repo.repository.stargazers.totalCount, + languagesBreakdown: data_languages.languagesBreakdown, }); }); it("should throw error if user is found but repo is null", async () => { + mock.onPost("https://api.github.com/graphql").replyOnce(200, { + data: { user: { repository: null }, organization: null }, + }); mock .onPost("https://api.github.com/graphql") - .reply(200, { data: { user: { repository: null }, organization: null } }); + .replyOnce(200, data_languages_resp); await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( "User Repository Not found", @@ -71,9 +119,12 @@ describe("Test fetchRepo", () => { }); it("should throw error if org is found but repo is null", async () => { + mock.onPost("https://api.github.com/graphql").replyOnce(200, { + data: { user: null, organization: { repository: null } }, + }); mock .onPost("https://api.github.com/graphql") - .reply(200, { data: { user: null, organization: { repository: null } } }); + .replyOnce(200, data_languages_resp); await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( "Organization Repository Not found", @@ -83,7 +134,10 @@ describe("Test fetchRepo", () => { it("should throw error if both user & org data not found", async () => { mock .onPost("https://api.github.com/graphql") - .reply(200, { data: { user: null, organization: null } }); + .replyOnce(200, { data: { user: null, organization: null } }); + mock + .onPost("https://api.github.com/graphql") + .replyOnce(200, data_languages_resp); await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( "Not found", @@ -91,12 +145,15 @@ describe("Test fetchRepo", () => { }); it("should throw error if repository is private", async () => { - mock.onPost("https://api.github.com/graphql").reply(200, { + mock.onPost("https://api.github.com/graphql").replyOnce(200, { data: { user: { repository: { ...data_repo, isPrivate: true } }, organization: null, }, }); + mock + .onPost("https://api.github.com/graphql") + .replyOnce(200, data_languages_resp); await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( "User Repository Not found", diff --git a/tests/pin.test.js b/tests/pin.test.js index 2583ddfe9e9af..daad7ad1fe925 100644 --- a/tests/pin.test.js +++ b/tests/pin.test.js @@ -22,6 +22,23 @@ const data_repo = { }, forkCount: 100, isTemplate: false, + languagesBreakdown: { + HTML: { + color: "#0f0", + name: "HTML", + size: 200, + }, + javascript: { + color: "#0ff", + name: "javascript", + size: 200, + }, + css: { + color: "#ff0", + name: "css", + size: 100, + }, + }, }, }; @@ -31,6 +48,19 @@ const data_user = { organization: null, }, }; +const data_languages_resp = { + data: { + repository: { + languages: { + edges: [ + { node: { name: "HTML", color: "#0f0" }, size: 200 }, + { node: { name: "javascript", color: "#0ff" }, size: 200 }, + { node: { name: "css", color: "#ff0" }, size: 100 }, + ], + }, + }, + }, +}; const mock = new MockAdapter(axios); @@ -50,8 +80,10 @@ describe("Test /api/pin", () => { setHeader: jest.fn(), send: jest.fn(), }; - mock.onPost("https://api.github.com/graphql").reply(200, data_user); - + mock.onPost("https://api.github.com/graphql").replyOnce(200, data_user); + mock + .onPost("https://api.github.com/graphql") + .replyOnce(200, data_languages_resp); await pin(req, res); expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); @@ -79,7 +111,11 @@ describe("Test /api/pin", () => { setHeader: jest.fn(), send: jest.fn(), }; - mock.onPost("https://api.github.com/graphql").reply(200, data_user); + // To Api calls + mock.onPost("https://api.github.com/graphql").replyOnce(200, data_user); + mock + .onPost("https://api.github.com/graphql") + .replyOnce(200, data_languages_resp); await pin(req, res); @@ -106,9 +142,12 @@ describe("Test /api/pin", () => { setHeader: jest.fn(), send: jest.fn(), }; + mock.onPost("https://api.github.com/graphql").replyOnce(200, { + data: { user: { repository: null }, organization: null }, + }); mock .onPost("https://api.github.com/graphql") - .reply(200, { data: { user: { repository: null }, organization: null } }); + .replyOnce(200, data_languages_resp); await pin(req, res); @@ -127,9 +166,12 @@ describe("Test /api/pin", () => { setHeader: jest.fn(), send: jest.fn(), }; + mock.onPost("https://api.github.com/graphql").replyOnce(200, { + data: { user: null, organization: { repository: null } }, + }); mock .onPost("https://api.github.com/graphql") - .reply(200, { data: { user: null, organization: { repository: null } } }); + .replyOnce(200, data_languages_resp); await pin(req, res); @@ -150,7 +192,10 @@ describe("Test /api/pin", () => { setHeader: jest.fn(), send: jest.fn(), }; - mock.onPost("https://api.github.com/graphql").reply(200, data_user); + mock.onPost("https://api.github.com/graphql").replyOnce(200, data_user); + mock + .onPost("https://api.github.com/graphql") + .replyOnce(200, data_languages_resp); await pin(req, res); @@ -172,7 +217,10 @@ describe("Test /api/pin", () => { setHeader: jest.fn(), send: jest.fn(), }; - mock.onPost("https://api.github.com/graphql").reply(200, data_user); + mock.onPost("https://api.github.com/graphql").replyOnce(200, data_user); + mock + .onPost("https://api.github.com/graphql") + .replyOnce(200, data_languages_resp); await pin(req, res); diff --git a/tests/renderRepoCard.test.js b/tests/renderRepoCard.test.js index abbad4dbe2a3b..e78cbfd1cadbd 100644 --- a/tests/renderRepoCard.test.js +++ b/tests/renderRepoCard.test.js @@ -18,6 +18,23 @@ const data_repo = { }, starCount: 38000, forkCount: 100, + languagesBreakdown: { + HTML: { + color: "#0f0", + name: "HTML", + size: 200, + }, + javascript: { + color: "#0ff", + name: "javascript", + size: 200, + }, + css: { + color: "#ff0", + name: "css", + size: 100, + }, + }, }, };