diff --git a/content/actions/guides/deploying-to-amazon-elastic-container-service.md b/content/actions/guides/deploying-to-amazon-elastic-container-service.md index 59b5b7bae2..023472a4cb 100644 --- a/content/actions/guides/deploying-to-amazon-elastic-container-service.md +++ b/content/actions/guides/deploying-to-amazon-elastic-container-service.md @@ -5,6 +5,7 @@ product: '{% data reusables.gated-features.actions %}' versions: free-pro-team: '*' enterprise-server: '>=2.22' +type: 'tutorial' --- {% data reusables.actions.enterprise-beta %} diff --git a/content/actions/guides/deploying-to-azure-app-service.md b/content/actions/guides/deploying-to-azure-app-service.md index 3471b333ac..ef83e1b85a 100644 --- a/content/actions/guides/deploying-to-azure-app-service.md +++ b/content/actions/guides/deploying-to-azure-app-service.md @@ -5,6 +5,7 @@ product: '{% data reusables.gated-features.actions %}' versions: free-pro-team: '*' enterprise-server: '>=2.22' +type: 'tutorial' --- {% data reusables.actions.enterprise-beta %} @@ -54,7 +55,7 @@ Before creating your {% data variables.product.prodname_actions %} workflow, you 3. Configure an Azure publish profile and create an `AZURE_WEBAPP_PUBLISH_PROFILE` secret. - Generate your Azure deployment credentials using a publish profile. For more information, see "[Generate deployment credentials](https://docs.microsoft.com/en-us/azure/app-service/deploy-github-actions?tabs=applevel#generate-deployment-credentials)" in the Azure documentation. + Generate your Azure deployment credentials using a publish profile. For more information, see "[Generate deployment credentials](https://docs.microsoft.com/en-us/azure/app-service/deploy-github-actions?tabs=applevel#generate-deployment-credentials)" in the Azure documentation. In your {% data variables.product.prodname_dotcom %} repository, create a secret named `AZURE_WEBAPP_PUBLISH_PROFILE` that contains the contents of the publish profile. For more information on creating secrets, see "[Encrypted secrets](/actions/reference/encrypted-secrets#creating-encrypted-secrets-for-a-repository)." diff --git a/content/actions/guides/deploying-to-google-kubernetes-engine.md b/content/actions/guides/deploying-to-google-kubernetes-engine.md index ed8256ebbf..325e0eedad 100644 --- a/content/actions/guides/deploying-to-google-kubernetes-engine.md +++ b/content/actions/guides/deploying-to-google-kubernetes-engine.md @@ -5,6 +5,7 @@ product: '{% data reusables.gated-features.actions %}' versions: free-pro-team: '*' enterprise-server: '>=2.22' +type: 'tutorial' --- {% data reusables.actions.enterprise-beta %} @@ -99,37 +100,37 @@ The following example workflow demonstrates how to build a container image and p {% raw %} ```yaml{:copy} name: Build and Deploy to GKE - + on: release: types: [created] - + env: PROJECT_ID: ${{ secrets.GKE_PROJECT }} GKE_CLUSTER: cluster-1 # Add your cluster name here. GKE_ZONE: us-central1-c # Add your cluster zone here. DEPLOYMENT_NAME: gke-test # Add your deployment name here. IMAGE: static-site - + jobs: setup-build-publish-deploy: name: Setup, Build, Publish, and Deploy runs-on: ubuntu-latest steps: - + - name: Checkout uses: actions/checkout@v2 - + # Setup gcloud CLI - uses: google-github-actions/setup-gcloud@v0.2.0 with: service_account_key: ${{ secrets.GKE_SA_KEY }} project_id: ${{ secrets.GKE_PROJECT }} - + # Configure docker to use the gcloud command-line tool as a credential helper - run: |- gcloud --quiet auth configure-docker - + # Get the GKE credentials so we can deploy to the cluster - uses: google-github-actions/get-gke-credentials@v0.2.1 with: @@ -145,18 +146,18 @@ jobs: --build-arg GITHUB_SHA="$GITHUB_SHA" \ --build-arg GITHUB_REF="$GITHUB_REF" \ . - + # Push the Docker image to Google Container Registry - name: Publish run: |- docker push "gcr.io/$PROJECT_ID/$IMAGE:$GITHUB_SHA" - + # Set up kustomize - name: Set up Kustomize run: |- curl -sfLo kustomize https://github.com/kubernetes-sigs/kustomize/releases/download/v3.1.0/kustomize_3.1.0_linux_amd64 chmod u+x ./kustomize - + # Deploy the Docker image to the GKE cluster - name: Deploy run: |- diff --git a/content/actions/index.md b/content/actions/index.md index 593b3f462c..75e30f185f 100644 --- a/content/actions/index.md +++ b/content/actions/index.md @@ -36,7 +36,7 @@ changelog: href: https://github.blog/changelog/2020-10-29-github-actions-ubuntu-latest-workflows-will-use-ubuntu-20-04 product_video: https://www.youtube-nocookie.com/embed/cP0I9w2coGU - + redirect_from: - /articles/automating-your-workflow-with-github-actions/ - /articles/customizing-your-project-with-github-actions/ @@ -72,7 +72,7 @@ versions: {% render 'code-example-card' for actionsCodeExamples as example %} - +
{% octicon "search" width="24" %}
diff --git a/content/discussions/index.md b/content/discussions/index.md index 66dca4f270..902d7f2a00 100644 --- a/content/discussions/index.md +++ b/content/discussions/index.md @@ -43,7 +43,7 @@ versions: {% render 'discussions-community-card' for discussionsCommunityExamples as example %}
{% if discussionsCommunityExamples.length > 6 %} - + {% endif %}
{% octicon "search" width="24" %}
diff --git a/data/ui.yml b/data/ui.yml index 3644182c6e..769025e23f 100644 --- a/data/ui.yml +++ b/data/ui.yml @@ -148,7 +148,13 @@ product_sublanding: learning_paths: Learning paths learning_paths_desc: Learning paths are a collection of guides that help you master a particular subject. more_guides: more guides - guideTypes: + load_more: Load more guides + all_guides: All Guides + no_result: Sorry, there is no guide that match your filter. + filters: + type: Type + all: All + guide_types: overview: Overview quick_start: Quickstart tutorial: Tutorial diff --git a/includes/article-cards.html b/includes/article-cards.html new file mode 100644 index 0000000000..72b21648ac --- /dev/null +++ b/includes/article-cards.html @@ -0,0 +1,43 @@ +{% assign currentCategory = siteTree[currentLanguage][currentVersion].products[currentProduct].categories[breadcrumbs.category.href] %} + +{% assign maxArticles = 9 %} + +
+

{% data ui.product_sublanding.all_guides %}

+ +
+ + +
+ + +
+ {% for article in currentCategory.articles %} + + {% assign card_display_class = "" %} + {% if forloop.index > maxArticles %} + {% assign card_display_class = "d-none" %} + {% endif %} + + {% capture link_card %} + {% link_as_article_card {{ article[1].href }} %} + {% endcapture %} + + {{ link_card | replace: "", card_display_class }} + {% endfor %} + + + +
+

{% data ui.product_sublanding.no_result %}

+
+
+
\ No newline at end of file diff --git a/includes/liquid-tags/link-as-article-card.html b/includes/liquid-tags/link-as-article-card.html new file mode 100644 index 0000000000..555cf1e0e2 --- /dev/null +++ b/includes/liquid-tags/link-as-article-card.html @@ -0,0 +1,7 @@ +
+ +

{{ title }}

+
{{ type }}
+

{{ intro }}

+
+
\ No newline at end of file diff --git a/javascripts/filter-cards.js b/javascripts/filter-cards.js index 986322b7ae..52280a2933 100644 --- a/javascripts/filter-cards.js +++ b/javascripts/filter-cards.js @@ -1,72 +1,104 @@ -function filterCards (cards, value) { - const noResults = document.querySelector('.js-filter-card-no-results') - const matchReg = new RegExp(value, 'i') - - // Track whether or not we had at least one match - let hasMatches = false - - for (let index = 0; index < cards.length; index++) { - const card = cards[index] - - // Filter was emptied - if (!value) { - // Make sure we don't show the "No results" blurb - hasMatches = true - - // Hide all but the first 6 - if (index > 5) { - card.classList.add('d-none') - } else { - card.classList.remove('d-none') - } - - continue - } - - // Check if this card matches - any `data-*` attribute contains the string - const cardMatches = Object.keys(card.dataset) - .some(key => matchReg.test(card.dataset[key])) - - if (cardMatches) { - card.classList.remove('d-none') - hasMatches = true - } else { - card.classList.add('d-none') - } - } - - // If there wasn't at least one match, show the "no results" text - if (!hasMatches) { - document.querySelector('.js-filter-card-value').textContent = value - noResults.classList.remove('d-none') - } else { - noResults.classList.add('d-none') - } +function matchCardBySearch (card, searchString) { + const matchReg = new RegExp(searchString, 'i') + // Check if this card matches - any `data-*` attribute contains the string + return Object.keys(card.dataset).some(key => matchReg.test(card.dataset[key])) } -export default function filterCodeExamples () { - const filter = document.querySelector('.js-filter-card-filter') +function matchCardByAttribute (card, attribute, value) { + if (attribute in card.dataset) { + return card.dataset[attribute] === value + } + return false +} + +export default function cardsFilter () { + const inputFilter = document.querySelector('.js-filter-card-filter') + const dropdownFilter = document.querySelector('.js-filter-card-filter-dropdown') const cards = Array.from(document.querySelectorAll('.js-filter-card')) const showMoreButton = document.querySelector('.js-filter-card-show-more') + const noResults = document.querySelector('.js-filter-card-no-results') + // if jsFilterCardMax not set, assume no limit (well, at 99) + const maxCards = showMoreButton ? parseInt(showMoreButton.dataset.jsFilterCardMax || 99) : null - if (filter) { - filter.addEventListener('keyup', evt => { - const value = evt.currentTarget.value + const filterEventHandler = (evt) => { + const { currentTarget } = evt + const value = currentTarget.value - // Show or hide the "Show more" button if there is a value - if (value) showMoreButton.classList.add('d-none') - else showMoreButton.classList.remove('d-none') + // Show or hide the "Show more" button if there is a value + if (value) { + showMoreButton.classList.add('d-none') + } else { + showMoreButton.classList.remove('d-none') + } - filterCards(cards, value) + // Track whether or not we had at least one match + let hasMatches = false + + for (let index = 0; index < cards.length; index++) { + const card = cards[index] + + // Filter was emptied + if (!value) { + // Make sure we don't show the "No results" blurb + hasMatches = true + + // Hide all but the first n number of cards + if (index > maxCards - 1) { + card.classList.add('d-none') + } else { + card.classList.remove('d-none') + } + + continue + } + + let cardMatches = false + + if (currentTarget.tagName === 'INPUT') { + cardMatches = matchCardBySearch(card, value) + } + + if (currentTarget.tagName === 'SELECT' && currentTarget.name) { + cardMatches = matchCardByAttribute(card, currentTarget.name, value) + } + + if (cardMatches) { + card.classList.remove('d-none') + hasMatches = true + } else { + card.classList.add('d-none') + } + } + + // If there wasn't at least one match, show the "no results" text + if (!hasMatches) { + noResults.classList.remove('d-none') + } else { + noResults.classList.add('d-none') + } + + return hasMatches + } + + if (inputFilter) { + inputFilter.addEventListener('keyup', (evt) => { + const hasMatches = filterEventHandler(evt) + if (!hasMatches) { + document.querySelector('.js-filter-card-value').textContent = evt.currentTarget.value + } }) } + if (dropdownFilter) { + dropdownFilter.addEventListener('change', filterEventHandler) + } + if (showMoreButton) { showMoreButton.addEventListener('click', evt => { // Number of cards that are currently visible const numShown = cards.filter(card => !card.classList.contains('d-none')).length - // We want to show 6 more - const totalToShow = numShown + 6 + // We want to show n more cards + const totalToShow = numShown + maxCards for (let index = numShown; index < cards.length; index++) { const card = cards[index] @@ -83,7 +115,7 @@ export default function filterCodeExamples () { // They're all shown now, we should hide the button if (totalToShow >= cards.length) { - evt.currentTarget.style.display = 'none' + evt.currentTarget.classList.add('d-none') } }) } diff --git a/javascripts/show-more.js b/javascripts/show-more.js index 00a23bba81..d67ef4d362 100644 --- a/javascripts/show-more.js +++ b/javascripts/show-more.js @@ -1,5 +1,5 @@ /* - * This utility makes it easy to implement a list of items, some of which are hidden initially + * This utility component implement a list of items, some of which are hidden initially * until user clicks "show more". * * Example: diff --git a/layouts/product-sublanding.html b/layouts/product-sublanding.html index 92b67c4429..d9af6ab059 100644 --- a/layouts/product-sublanding.html +++ b/layouts/product-sublanding.html @@ -1,5 +1,5 @@ -{% assign guideTypes = site.data.ui.product_sublanding.guideTypes %} +{% assign guideTypes = site.data.ui.product_sublanding.guide_types %} {% include head %} @@ -42,7 +42,7 @@
{{ forloop.index }}
-
{{ guideTypes[guide.type] }}
+
{{ guideTypes[guide.page.type] }}

{{ guide.title }}

{{ guide.intro }}
@@ -87,7 +87,7 @@ {{ forloop.index }}

{{ guide.title }}

-
{{ guideTypes[guide.type] }}
+
{{ guideTypes[guide.page.type] }}
{% endfor %} @@ -106,9 +106,7 @@
-
- -
+ {% include 'article-cards' %}
diff --git a/lib/get-link-data.js b/lib/get-link-data.js index 0d7a078d0a..46a64d7730 100644 --- a/lib/get-link-data.js +++ b/lib/get-link-data.js @@ -5,7 +5,7 @@ const removeFPTFromPath = require('./remove-fpt-from-path') // rawLinks is an array of paths: [ '/foo' ] // we need to convert it to an array of localized objects: [ { href: '/en/foo', title: 'Foo', intro: 'Description here' } ] -module.exports = async (rawLinks, context, additionalProperties = []) => { +module.exports = async (rawLinks, context) => { if (!rawLinks) return const links = [] @@ -20,17 +20,11 @@ module.exports = async (rawLinks, context, additionalProperties = []) => { const opts = { textOnly: true, encodeEntities: true } - const props = {} - for (const propName of additionalProperties) { - props[propName] = linkedPage[propName] - } - links.push({ href, title: await linkedPage.renderTitle(context, opts), intro: await linkedPage.renderProp('intro', context, opts), - page: linkedPage, - ...props + page: linkedPage }) } diff --git a/lib/liquid-tags/link-as-article-card.js b/lib/liquid-tags/link-as-article-card.js new file mode 100644 index 0000000000..060e3989ee --- /dev/null +++ b/lib/liquid-tags/link-as-article-card.js @@ -0,0 +1,10 @@ +const Link = require('./link') + +// For details, see class method in lib/liquid-tags/link.js +module.exports = class LinkAsArticleCard extends Link { + async renderPageProps (page, ctx, props) { + const renderedProps = await super.renderPageProps(page, ctx, props) + const { type } = page + return { ...renderedProps, type } + } +} diff --git a/lib/liquid-tags/link.js b/lib/liquid-tags/link.js index 5239dc98c5..d82449e2b0 100644 --- a/lib/liquid-tags/link.js +++ b/lib/liquid-tags/link.js @@ -5,6 +5,7 @@ const liquid = new Liquid.Engine() const LiquidTag = require('./liquid-tag') const findPage = require('../find-page') const getApplicableVersions = require('../get-applicable-versions') +const { getPathWithLanguage } = require('../path-utils') // This class supports a set of link tags. Each tag expects one parameter, a language-agnostic href: // @@ -27,7 +28,18 @@ module.exports = class Link extends LiquidTag { super(template, tagName, href.trim()) } - async parseTemplate (context) { + async renderPageProps (page, ctx, props) { + const renderedProps = {} + + for (const propName in props) { + const { opt } = props[propName] || {} + renderedProps[propName] = await page.renderProp(propName, ctx, opt) + } + + return renderedProps + } + + async parseTemplate (context, opts = { shortTitle: false }) { const template = await this.getTemplate() const ctx = context.environments[0] @@ -38,7 +50,16 @@ module.exports = class Link extends LiquidTag { assert(ctx.currentLanguage, 'context.currentLanguage is required') // process any liquid in hrefs (e.g., /enterprise/{{ page.version }}) - const href = await liquid.parseAndRender(this.param, ctx) + let href = await liquid.parseAndRender(this.param, ctx) + + // process variable defined in page scope + if (href === '') { + const match = liquidVariableSyntax.exec(this.param) + if (match) { + const variable = new Liquid.Variable(match[1]) + href = await variable.render(context) + } + } let fullPath = href const dirName = path.dirname(ctx.page.relativePath) @@ -52,7 +73,7 @@ module.exports = class Link extends LiquidTag { } // add language code - fullPath = path.join('/', ctx.currentLanguage, fullPath) + fullPath = getPathWithLanguage(fullPath, ctx.currentLanguage) // find the page based on the full path const page = findPage(fullPath, ctx.pages, ctx.redirects) @@ -61,20 +82,21 @@ module.exports = class Link extends LiquidTag { if (!page || (page.hidden && !ctx.page.hidden)) { return '' } - // also return early if the found page should not render in current version if (!getApplicableVersions(page.versions).includes(ctx.currentVersion)) { return '' } // find and render the props - const title = await page.renderProp('title', ctx, { textOnly: true, encodeEntities: true }) + const renderedProps = await this.renderPageProps(page, ctx, { + title: { opt: { textOnly: true, encodeEntities: true } }, + intro: { opt: { unwrap: true } } + }) - // we want markdown in intros to be parsed, so we do not pass textOnly here - const intro = await page.renderProp('intro', ctx, { unwrap: true }) - - const parsed = await liquid.parseAndRender(template, { fullPath, title, intro }) + const parsed = await liquid.parseAndRender(template, { fullPath, ...renderedProps }) return parsed.trim() } } + +const liquidVariableSyntax = RegExp(`^${Liquid.VariableStart.source}\\s*(.*)\\s*${Liquid.VariableEnd.source}`) diff --git a/lib/page.js b/lib/page.js index 3e88491a0d..a7072b7bde 100644 --- a/lib/page.js +++ b/lib/page.js @@ -210,7 +210,7 @@ class Page { learningTracks.push({ title: await renderContent(track.title, context, { textOnly: true, encodeEntities: true }), description: await renderContent(track.description, context, { textOnly: true, encodeEntities: true }), - guides: await getLinkData(track.guides, context, ['type']) + guides: await getLinkData(track.guides, context) }) } this.learningTracks = learningTracks diff --git a/lib/render-content/index.js b/lib/render-content/index.js index 20122bc1eb..3394225894 100644 --- a/lib/render-content/index.js +++ b/lib/render-content/index.js @@ -12,6 +12,7 @@ renderContent.liquid.registerTag('topic_link_in_list', require('../liquid-tags/t renderContent.liquid.registerTag('indented_data_reference', require('../liquid-tags/indented-data-reference')) renderContent.liquid.registerTag('data', require('../liquid-tags/data')) renderContent.liquid.registerTag('octicon', require('../liquid-tags/octicon')) +renderContent.liquid.registerTag('link_as_article_card', require('../liquid-tags/link-as-article-card')) for (const tag in tags) { // Register all the extended markdown tags, like {% note %} and {% warning %} diff --git a/middleware/csp.js b/middleware/csp.js index cbf6119f6e..bd5a3402c2 100644 --- a/middleware/csp.js +++ b/middleware/csp.js @@ -22,6 +22,7 @@ module.exports = async (req, res, next) => { ], imgSrc: [ "'self'", + 'data:', 'github.githubassets.com', 'github-images.s3.amazonaws.com', 'octodex.github.com', diff --git a/tests/browser/browser.js b/tests/browser/browser.js index 597d301b56..956b52f216 100644 --- a/tests/browser/browser.js +++ b/tests/browser/browser.js @@ -249,3 +249,53 @@ describe('platform specific content', () => { } }) }) + +describe('card filters', () => { + it('loads correctly', async () => { + await page.goto('http://localhost:4001/en/actions') + const shownCards = await page.$$('.js-filter-card:not(.d-none)') + const shownNoResult = await page.$('.js-filter-card-no-results:not(.d-none)') + const maxCards = await page.$eval('.js-filter-card-show-more', btn => parseInt(btn.dataset.jsFilterCardMax)) + expect(shownCards.length).toBe(maxCards) + expect(shownNoResult).toBeNull() + }) + + it('filters cards', async () => { + await page.goto('http://localhost:4001/en/actions') + await page.click('input.js-filter-card-filter') + await page.type('input.js-filter-card-filter', 'issues') + const shownCards = await page.$$('.js-filter-card:not(.d-none)') + const showMoreClasses = await page.$eval('.js-filter-card-show-more', btn => Object.values(btn.classList)) + expect(showMoreClasses).toContain('d-none') + expect(shownCards.length).toBeGreaterThan(1) + }) + + it('works with select input', async () => { + await page.goto('http://localhost:4001/en/actions/guides') + await page.select('.js-filter-card-filter-dropdown[name="type"]', 'overview') + const shownCards = await page.$$('.js-filter-card:not(.d-none)') + const shownCardsAttrib = await page.$$eval('.js-filter-card:not(.d-none)', cards => + cards.map(card => card.dataset.type) + ) + shownCardsAttrib.map(attrib => expect(attrib).toBe('overview')) + expect(shownCards.length).toBeGreaterThan(0) + }) + + it('shows more cards', async () => { + await page.goto('http://localhost:4001/en/actions') + const maxCards = await page.$eval('.js-filter-card-show-more', btn => parseInt(btn.dataset.jsFilterCardMax)) + await page.click('.js-filter-card-show-more') + const shownCards = await page.$$('.js-filter-card:not(.d-none)') + expect(shownCards.length).toBe(maxCards * 2) + }) + + it('displays no result message', async () => { + await page.goto('http://localhost:4001/en/actions') + await page.click('input.js-filter-card-filter') + await page.type('input.js-filter-card-filter', 'this should not work') + const shownCards = await page.$$('.js-filter-card:not(.d-none)') + expect(shownCards.length).toBe(0) + const noResultsClasses = await page.$eval('.js-filter-card-no-results', elem => Object.values(elem.classList)) + expect(noResultsClasses).not.toContain('d-none') + }) +}) diff --git a/tests/unit/liquid-helpers.js b/tests/unit/liquid-helpers.js index fdf2073a7b..49509ce72b 100644 --- a/tests/unit/liquid-helpers.js +++ b/tests/unit/liquid-helpers.js @@ -45,6 +45,16 @@ describe('liquid helper tags', () => { const expected = '' const output = await liquid.parseAndRender(template, context) expect(output.includes(expected)).toBe(true) + // set this back to english + context.currentLanguage = 'en' + }) + + test('link tag with local variable', async () => { + const template = `{% assign href = "/contributing-and-collaborating-using-github-desktop" %} + {% link {{ href }} %}` + const expected = '' + const output = await liquid.parseAndRender(template, context) + expect(output.includes(expected)).toBe(true) }) test('link tag with absolute path', async () => { @@ -84,6 +94,19 @@ describe('liquid helper tags', () => { expect(output).toBe(expected) }) + test('link_as_article_card', async () => { + const template = '{% link_as_article_card /contributing-and-collaborating-using-github-desktop %}' + const expected = `
+ +

Contributing and collaborating using GitHub Desktop

+
+

Use GitHub Desktop to manage your projects, create meaningful commits, and track the project's history in an app instead of on the command line.

+
+
` + const output = await liquid.parseAndRender(template, context) + expect(output).toBe(expected) + }) + describe('indented_data_reference tag', () => { test('without any number of spaces specified', async () => { const template = '{% indented_data_reference site.data.reusables.example %}' diff --git a/tests/unit/page.js b/tests/unit/page.js index a2e10a3c6d..d86452f6ad 100644 --- a/tests/unit/page.js +++ b/tests/unit/page.js @@ -335,7 +335,7 @@ describe('Page class', () => { } } await page.render(context) - expect(getLinkData).toHaveBeenCalledWith(guides, context, ['type']) + expect(getLinkData).toHaveBeenCalledWith(guides, context) expect(page.learningTracks).toHaveLength(2) }) })