// ===--- index.js - Swift Evolution --------------------------------------===// // // This source file is part of the Swift.org open source project // // Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors // Licensed under Apache License v2.0 with Runtime Library Exception // // See http://swift.org/LICENSE.txt for license information // See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // // ===---------------------------------------------------------------------===// 'use strict' /** Holds the primary data used on this page: metadata about Swift Evolution proposals. */ var proposals /** * To be updated when proposals are confirmed to have been implemented * in a new language version. */ var languageVersions = ['2.2', '3', '3.0.1', '3.1', '4', '4.1', '4.2', '5', '5.1', '5.2', '5.3', '5.4', '5.5', 'Next'] /** Storage for the user's current selection of filters when filtering is toggled off. */ var filterSelection = [] var GITHUB_BASE_URL = 'https://github.com/' var REPO_PROPOSALS_BASE_URL = GITHUB_BASE_URL + 'apple/swift-evolution/blob/main/proposals' /** * `name`: Mapping of the states in the proposals JSON to human-readable names. * * `shortName`: Mapping of the states in the proposals JSON to short human-readable names. * Used for the left-hand column of proposal statuses. * * `className`: Mapping of states in the proposals JSON to the CSS class names used * to manipulate and display proposals based on their status. * * `count`: Number of proposals that determine after all proposals is loaded */ var states = { '.awaitingReview': { name: 'Awaiting Review', shortName: 'Awaiting Review', className: 'awaiting-review', count: 0 }, '.scheduledForReview': { name: 'Scheduled for Review', shortName: 'Scheduled', className: 'scheduled-for-review', count: 0 }, '.activeReview': { name: 'Active Review', shortName: 'Active Review', className: 'active-review', count: 0 }, '.returnedForRevision': { name: 'Returned for Revision', shortName: 'Returned', className: 'returned-for-revision', count: 0 }, '.withdrawn': { name: 'Withdrawn', shortName: 'Withdrawn', className: 'withdrawn', count: 0 }, '.deferred': { name: 'Deferred', shortName: 'Deferred', className: 'deferred', count: 0 }, '.accepted': { name: 'Accepted', shortName: 'Accepted', className: 'accepted', count: 0 }, '.acceptedWithRevisions': { name: 'Accepted with revisions', shortName: 'Accepted', className: 'accepted-with-revisions', count: 0 }, '.rejected': { name: 'Rejected', shortName: 'Rejected', className: 'rejected', count: 0 }, '.implemented': { name: 'Implemented', shortName: 'Implemented', className: 'implemented', count: 0 }, '.previewing': { name: 'Previewing', shortName: 'Previewing', className: 'previewing', count: 0 }, '.error': { name: 'Error', shortName: 'Error', className: 'error', count: 0 } } init() /** Primary entry point. */ function init () { var req = new window.XMLHttpRequest() req.addEventListener('load', function (e) { proposals = JSON.parse(req.responseText) // don't display malformed proposals proposals = proposals.filter(function (proposal) { return !proposal.errors }) // descending numeric sort based the numeric nnnn in a proposal ID's SE-nnnn proposals.sort(function compareProposalIDs (p1, p2) { return parseInt(p1.id.match(/\d\d\d\d/)[0]) - parseInt(p2.id.match(/\d\d\d\d/)[0]) }) proposals = proposals.reverse() render() addEventListeners() // apply filters when the page loads with a search already filled out. // typically this happens after navigating backwards in a tab's history. if (document.querySelector('#search-filter').value.trim()) { filterProposals() } // apply selections from the current page's URI fragment _applyFragment(document.location.hash) }) req.addEventListener('error', function (e) { document.querySelector('#proposals-count-number').innerText = 'Proposal data failed to load.' }) document.querySelector('#proposals-count-number').innerHTML = 'Loading ...' req.open('get', 'https://data.swift.org/swift-evolution/proposals') req.send() } /** * Creates an Element. Convenience wrapper for `document.createElement`. * * @param {string} elementType - The tag name. 'div', 'span', etc. * @param {string[]} attributes - A list of attributes. Use `className` for `class`. * @param {(string | Element)[]} children - A list of either text or other Elements to be nested under this Element. * @returns {Element} The new node. */ function html (elementType, attributes, children) { var element = document.createElement(elementType) if (attributes) { Object.keys(attributes).forEach(function (attributeName) { var value = attributes[attributeName] if (attributeName === 'className') attributeName = 'class' element.setAttribute(attributeName, value) }) } if (!children) return element if (!Array.isArray(children)) children = [children] children.forEach(function (child) { if (!child) { console.warn('Null child ignored during creation of ' + elementType) return } if (Object.getPrototypeOf(child) === String.prototype) { child = document.createTextNode(child) } element.appendChild(child) }) return element } function determineNumberOfProposals (proposals) { // reset count Object.keys(states).forEach(function (state){ states[state].count = 0 }) proposals.forEach(function (proposal) { states[proposal.status.state].count += 1 }) // .acceptedWithRevisions proposals are combined in the filtering UI // with .accepted proposals. states['.accepted'].count += states['.acceptedWithRevisions'].count } /** * Adds the dynamic portions of the page to the DOM, primarily the list * of proposals and list of statuses used for filtering. * * These `render` functions are only called once when the page loads, * the rest of the interactivity is based on toggling `display: none`. */ function render () { renderNav() renderBody() } /** Renders the top navigation bar. */ function renderNav () { var nav = document.querySelector('nav') // This list intentionally omits .acceptedWithRevisions and .error; // .acceptedWithRevisions proposals are combined in the filtering UI // with .accepted proposals. var checkboxes = [ '.awaitingReview', '.scheduledForReview', '.activeReview', '.accepted', '.previewing', '.implemented', '.returnedForRevision', '.deferred', '.rejected', '.withdrawn' ].map(function (state) { var className = states[state].className return html('li', null, [ html('input', { type: 'checkbox', className: 'filtered-by-status', id: 'filter-by-' + className, value: className }), html('label', { className: className, tabindex: '0', role: 'button', 'for': 'filter-by-' + className, 'data-state-key': state }, [ addNumberToState(states[state].name, states[state].count) ]) ]) }) var expandableArea = html('div', { className: 'filter-options expandable' }, [ html('h5', { id: 'filter-options-label' }, 'Status'), html('ul', { id: 'filter-options', className: 'filter-by-status' }) ]) nav.querySelector('.nav-contents').appendChild(expandableArea) checkboxes.forEach(function (box) { nav.querySelector('.filter-by-status').appendChild(box) }) // The 'Implemented' filter selection gets an extra row of options if selected. var implementedCheckboxIfPresent = checkboxes.filter(function (cb) { return cb.querySelector(`#filter-by-${states['.implemented'].className}`) })[0] if (implementedCheckboxIfPresent) { // add an extra row of options to filter by language version var versionRowHeader = html('h5', { id: 'version-options-label', className: 'hidden' }, 'Language Version') var versionRow = html('ul', { id: 'version-options', className: 'filter-by-status hidden' }) var versionOptions = languageVersions.map(function (version) { return html('li', null, [ html('input', { type: 'checkbox', id: 'filter-by-swift-' + _idSafeName(version), className: 'filter-by-swift-version', value: 'swift-' + _idSafeName(version) }), html('label', { tabindex: '0', role: 'button', 'for': 'filter-by-swift-' + _idSafeName(version) }, 'Swift ' + version) ]) }) versionOptions.forEach(function (version) { versionRow.appendChild(version) }) expandableArea.appendChild(versionRowHeader) expandableArea.appendChild(versionRow) } return nav } /** Displays the main list of proposals that takes up the majority of the page. */ function renderBody () { var article = document.querySelector('article') var proposalAttachPoint = article.querySelector('.proposals-list') var proposalPresentationOrder = [ '.awaitingReview', '.scheduledForReview', '.activeReview', '.accepted', '.acceptedWithRevisions', '.previewing', '.implemented', '.returnedForRevision', '.deferred', '.rejected', '.withdrawn' ] proposalPresentationOrder.map(function (state) { var matchingProposals = proposals.filter(function (p) { return p.status && p.status.state === state }) matchingProposals.map(function (proposal) { var proposalBody = html('section', { id: proposal.id, className: 'proposal ' + proposal.id }, [ html('div', { className: 'status-pill-container' }, [ html('span', { className: 'status-pill color-' + states[state].className }, [ states[proposal.status.state].shortName ]) ]), html('div', { className: 'proposal-content' }, [ html('div', { className: 'proposal-header' }, [ html('span', { className: 'proposal-id' }, [ proposal.id ]), html('h4', { className: 'proposal-title' }, [ html('a', { href: REPO_PROPOSALS_BASE_URL + '/' + proposal.link, target: '_blank' }, [ proposal.title ]) ]) ]) ]) ]) var detailNodes = [] detailNodes.push(renderAuthors(proposal.authors)) if (proposal.reviewManager.name) detailNodes.push(renderReviewManager(proposal.reviewManager)) if (proposal.trackingBugs) detailNodes.push(renderTrackingBugs(proposal.trackingBugs)) if (state === '.implemented') detailNodes.push(renderVersion(proposal.status.version)) if (state === '.previewing') detailNodes.push(renderPreview()) if (proposal.implementation) detailNodes.push(renderImplementation(proposal.implementation)) if (state === '.acceptedWithRevisions') detailNodes.push(renderStatus(proposal.status)) if (state === '.activeReview' || state === '.scheduledForReview') { detailNodes.push(renderStatus(proposal.status)) detailNodes.push(renderReviewPeriod(proposal.status)) } if (state === '.returnedForRevision') { detailNodes.push(renderStatus(proposal.status)) } var details = html('div', { className: 'proposal-details' }, detailNodes) proposalBody.querySelector('.proposal-content').appendChild(details) proposalAttachPoint.appendChild(proposalBody) }) }) // Update the "(n) proposals" text updateProposalsCount(article.querySelectorAll('.proposal').length) return article } /** Authors have a `name` and optional `link`. */ function renderAuthors (authors) { var authorNodes = authors.map(function (author) { if (author.link.length > 0) { return html('a', { href: author.link, target: '_blank' }, author.name) } else { return document.createTextNode(author.name) } }) authorNodes = _joinNodes(authorNodes, ', ') return html('div', { className: 'authors proposal-detail' }, [ html('div', { className: 'proposal-detail-label' }, authors.length > 1 ? 'Authors: ' : 'Author: ' ), html('div', { className: 'proposal-detail-value' }, authorNodes) ]) } /** Review managers have a `name` and optional `link`. */ function renderReviewManager (reviewManager) { return html('div', { className: 'review-manager proposal-detail' }, [ html('div', { className: 'proposal-detail-label' }, 'Review Manager: '), html('div', { className: 'proposal-detail-value' }, [ reviewManager.link ? html('a', { href: reviewManager.link, target: '_blank' }, reviewManager.name) : reviewManager.name ]) ]) } /** Tracking bugs linked in a proposal are updated via bugs.swift.org. */ function renderTrackingBugs (bugs) { var bugNodes = bugs.map(function (bug) { return html('a', { href: bug.link, target: '_blank' }, [ bug.id, ' (', bug.assignee || 'Unassigned', ', ', bug.status, ')' ]) }) bugNodes = _joinNodes(bugNodes, ', ') return html('div', { className: 'proposal-detail' }, [ html('div', { className: 'proposal-detail-label' }, [ bugs.length > 1 ? 'Bugs: ' : 'Bug: ' ]), html('div', { className: 'bug-list proposal-detail-value' }, bugNodes ) ]) } /** Implementations are required alongside proposals (after Swift 4.0). */ function renderImplementation (implementations) { var implNodes = implementations.map(function (impl) { return html('a', { href: GITHUB_BASE_URL + impl.account + '/' + impl.repository + '/' + impl.type + '/' + impl.id }, [ impl.repository, impl.type === 'pull' ? '#' : '@', impl.id.substr(0, 7) ]) }) implNodes = _joinNodes(implNodes, ', ') var label = 'Implementation: ' return html('div', { className: 'proposal-detail' }, [ html('div', { className: 'proposal-detail-label' }, [label]), html('div', { className: 'implementation-list proposal-detail-value' }, implNodes ) ]) } /** For `.previewing` proposals, link to the stdlib preview package. */ function renderPreview () { return html('div', { className: 'proposal-detail' }, [ html('div', { className: 'proposal-detail-label' }, [ 'Preview: ' ]), html('div', { className: 'proposal-detail-value' }, [ html('a', { href: 'https://github.com/apple/swift-standard-library-preview', target: '_blank' }, 'Standard Library Preview' ) ]) ]) } /** For `.implemented` proposals, display the version of Swift in which they first appeared. */ function renderVersion (version) { return html('div', { className: 'proposal-detail' }, [ html('div', { className: 'proposal-detail-label' }, [ 'Implemented In: ' ]), html('div', { className: 'proposal-detail-value' }, [ 'Swift ' + version ]) ]) } /** For some proposal states like `.activeReview`, it helps to see the status in the same details list. */ function renderStatus (status) { return html('div', { className: 'proposal-detail' }, [ html('div', { className: 'proposal-detail-label' }, [ 'Status: ' ]), html('div', { className: 'proposal-detail-value' }, [ states[status.state].name ]) ]) } /** * Review periods are ISO-8601-style 'YYYY-MM-DD' dates. */ function renderReviewPeriod (status) { var months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ] var start = new Date(status.start) var end = new Date(status.end) var startMonth = start.getUTCMonth() var endMonth = end.getUTCMonth() var detailNodes = [months[startMonth], ' '] if (startMonth === endMonth) { detailNodes.push( start.getUTCDate().toString(), '–', end.getUTCDate().toString() ) } else { detailNodes.push( start.getUTCDate().toString(), ' – ', months[endMonth], ' ', end.getUTCDate().toString() ) } return html('div', { className: 'proposal-detail' }, [ html('div', { className: 'proposal-detail-label' }, [ 'Scheduled: ' ]), html('div', { className: 'proposal-detail-value' }, detailNodes) ]) } /** Utility used by some of the `render*` functions to add comma text nodes between DOM nodes. */ function _joinNodes (nodeList, text) { return nodeList.map(function (node) { return [node, text] }).reduce(function (result, pair, index, pairs) { if (index === pairs.length - 1) pair.pop() return result.concat(pair) }, []) } /** Adds UI interactivity to the page. Primarily activates the filtering controls. */ function addEventListeners () { var nav = document.querySelector('nav') // typing in the search field causes the filter to be reapplied. nav.addEventListener('keyup', filterProposals) nav.addEventListener('change', filterProposals) // clearing the search field also hides the X symbol nav.querySelector('#clear-button').addEventListener('click', function () { nav.querySelector('#search-filter').value = '' nav.querySelector('#clear-button').classList.toggle('hidden') filterProposals() }) // each of the individual statuses needs to trigger filtering as well ;[].forEach.call(nav.querySelectorAll('.filter-by-status input'), function (element) { element.addEventListener('change', filterProposals) }) var expandableArea = document.querySelector('.filter-options') var implementedToggle = document.querySelector('#filter-by-implemented') implementedToggle.addEventListener('change', function () { // hide or show the row of version options depending on the status of the 'Implemented' option ;['#version-options', '#version-options-label'].forEach(function (selector) { expandableArea.querySelector(selector).classList.toggle('hidden') }) // don't persist any version selections when the row is hidden ;[].concat.apply([], expandableArea.querySelectorAll('.filter-by-swift-version')).forEach(function (versionCheckbox) { versionCheckbox.checked = false }) }) document.querySelector('.filter-button').addEventListener('click', toggleFiltering) var filterToggle = document.querySelector('.filter-toggle') filterToggle.querySelector('.toggle-filter-panel').addEventListener('click', toggleFilterPanel) // Behavior conditional on certain browser features var CSS = window.CSS if (CSS) { // emulate position: sticky when it isn't available. if (!(CSS.supports('position', 'sticky') || CSS.supports('position', '-webkit-sticky'))) { window.addEventListener('scroll', function () { var breakpoint = document.querySelector('header').getBoundingClientRect().bottom var nav = document.querySelector('nav') var position = window.getComputedStyle(nav).position var shadowNav // maintain the main content height when the main 'nav' is removed from the flow // this is measuring whether or not the header has scrolled offscreen if (breakpoint <= 0) { if (position !== 'fixed') { shadowNav = nav.cloneNode(true) shadowNav.classList.add('clone') shadowNav.style.visibility = 'hidden' nav.parentNode.insertBefore(shadowNav, document.querySelector('main')) nav.style.position = 'fixed' } } else if (position === 'fixed') { nav.style.position = 'static' shadowNav = document.querySelector('nav.clone') if (shadowNav) shadowNav.parentNode.removeChild(shadowNav) } }) } } // on smaller screens, hide the filter panel when scrolling if (window.matchMedia('(max-width: 414px)').matches) { window.addEventListener('scroll', function () { var breakpoint = document.querySelector('header').getBoundingClientRect().bottom if (breakpoint <= 0 && document.querySelector('.expandable').classList.contains('expanded')) { toggleFilterPanel() } }) } } /** * Toggles whether filters are active. Rather than being cleared, they are saved to be restored later. * Additionally, toggles the presence of the "Filtered by:" status indicator. */ function toggleFiltering () { var filterDescription = document.querySelector('.filter-toggle') var shouldPreserveSelection = !filterDescription.classList.contains('hidden') filterDescription.classList.toggle('hidden') var selected = document.querySelectorAll('.filter-by-status input[type=checkbox]:checked') var filterButton = document.querySelector('.filter-button') if (shouldPreserveSelection) { filterSelection = [].map.call(selected, function (checkbox) { return checkbox.id }) ;[].forEach.call(selected, function (checkbox) { checkbox.checked = false }) filterButton.setAttribute('aria-pressed', 'false') } else { // restore it filterSelection.forEach(function (id) { var checkbox = document.getElementById(id) checkbox.checked = true }) filterButton.setAttribute('aria-pressed', 'true') } document.querySelector('.expandable').classList.remove('expanded') filterButton.classList.toggle('active') filterProposals() } /** * Expands or contracts the filter panel, which contains buttons that * let users filter proposals based on their current stage in the * Swift Evolution process. */ function toggleFilterPanel () { var panel = document.querySelector('.expandable') var button = document.querySelector('.toggle-filter-panel') panel.classList.toggle('expanded') if (panel.classList.contains('expanded')) { button.setAttribute('aria-pressed', 'true') } else { button.setAttribute('aria-pressed', 'false') } } /** * Applies both the status-based and text-input based filters to the proposals list. */ function filterProposals () { var filterElement = document.querySelector('#search-filter') var filter = filterElement.value var clearButton = document.querySelector('#clear-button') if (filter.length === 0) { clearButton.classList.add('hidden') } else { clearButton.classList.remove('hidden') } var matchingSets = [proposals.concat()] // Comma-separated lists of proposal IDs are treated as an "or" search. if (filter.match(/(SE-\d\d\d\d)($|((,SE-\d\d\d\d)+))/i)) { var proposalIDs = filter.split(',').map(function (id) { return id.toUpperCase() }) matchingSets[0] = matchingSets[0].filter(function (proposal) { return proposalIDs.indexOf(proposal.id) !== -1 }) } else if (filter.trim().length !== 0) { // The search input treats words as order-independent. matchingSets = filter.split(/\s/) .filter(function (s) { return s.length > 0 }) .map(function (part) { return _searchProposals(part) }) } var intersection = matchingSets.reduce(function (intersection, candidates) { return intersection.filter(function (alreadyIncluded) { return candidates.indexOf(alreadyIncluded) !== -1 }) }, matchingSets[0] || []) _applyFilter(intersection) _updateURIFragment() determineNumberOfProposals(intersection) updateFilterStatus() } /** * Utility used by `filterProposals`. * * Picks out various fields in a proposal which users may want to key * off of in their text-based filtering. * * @param {string} filterText - A raw word of text as entered by the user. * @returns {Proposal[]} The proposals that match the entered text, taken from the global list. */ function _searchProposals (filterText) { var filterExpression = filterText.toLowerCase() var searchableProperties = [ ['id'], ['title'], ['reviewManager', 'name'], ['status', 'state'], ['status', 'version'], ['authors', 'name'], ['authors', 'link'], ['implementation', 'account'], ['implementation', 'repository'], ['implementation', 'id'], ['trackingBugs', 'link'], ['trackingBugs', 'status'], ['trackingBugs', 'id'], ['trackingBugs', 'assignee'] ] // reflect over the proposals and find ones with matching properties var matchingProposals = proposals.filter(function (proposal) { var match = false searchableProperties.forEach(function (propertyList) { var value = proposal propertyList.forEach(function (propertyName, index) { if (!value) return value = value[propertyName] if (index < propertyList.length - 1) { // For arrays, apply the property check to each child element. // Note that this only looks to a depth of one property. if (Array.isArray(value)) { var matchCondition = value.some(function (element) { return element[propertyList[index + 1]] && element[propertyList[index + 1]].toString().toLowerCase().indexOf(filterExpression) >= 0 }) if (matchCondition) { match = true } } else { return } } else if (value && value.toString().toLowerCase().indexOf(filterExpression) >= 0) { match = true } }) }) return match }) return matchingProposals } /** * Helper for `filterProposals` that actually makes the filter take effect. * * @param {Proposal[]} matchingProposals - The proposals that have passed the text filtering phase. * @returns {Void} Toggles `display: hidden` to apply the filter. */ function _applyFilter (matchingProposals) { // filter out proposals based on the grouping checkboxes var allStateCheckboxes = document.querySelector('nav').querySelectorAll('.filter-by-status input:checked') var selectedStates = [].map.call(allStateCheckboxes, function (checkbox) { return checkbox.value }) var selectedStateNames = [].map.call(allStateCheckboxes, function (checkbox) { return checkbox.nextElementSibling.innerText.trim() }) updateFilterDescription(selectedStateNames) if (selectedStates.length) { matchingProposals = matchingProposals .filter(function (proposal) { return selectedStates.some(function (state) { return proposal.status.state.toLowerCase().indexOf(state.split('-')[0]) >= 0 }) }) // handle version-specific filtering options if (selectedStates.some(function (state) { return state.match(/swift/i) })) { matchingProposals = matchingProposals .filter(function (proposal) { return selectedStates.some(function (state) { if (!(proposal.status.state === '.implemented')) return true // only filter among Implemented (N.N.N) if (state === 'swift-swift-Next' && proposal.status.version === 'Next') return true // special case var version = state.split(/\D+/).filter(function (s) { return s.length }).join('.') if (!version.length) return false // it's not a state that represents a version number if (proposal.status.version === version) return true return false }) }) } } var filteredProposals = proposals.filter(function (proposal) { return matchingProposals.indexOf(proposal) === -1 }) matchingProposals.forEach(function (proposal) { var matchingElements = [].concat.apply([], document.querySelectorAll('.' + proposal.id)) matchingElements.forEach(function (element) { element.classList.remove('hidden') }) }) filteredProposals.forEach(function (proposal) { var filteredElements = [].concat.apply([], document.querySelectorAll('.' + proposal.id)) filteredElements.forEach(function (element) { element.classList.add('hidden') }) }) updateProposalsCount(matchingProposals.length) } /** * Parses a URI fragment and applies a search and filters to the page. * * Syntax (a query string within a fragment): * fragment --> `#?` parameter-value-list * parameter-value-list --> parameter-value | parameter-value-pair `&` parameter-value-list * parameter-value-pair --> parameter `=` value * parameter --> `proposal` | `status` | `version` | `search` * value --> ** Any URL-encoded text. ** * * For example: * /#?proposal:SE-0180,SE-0123 * /#?status=rejected&version=3&search=access * * Four types of parameters are supported: * - proposal: A comma-separated list of proposal IDs. Treated as an 'or' search. * - filter: A comma-separated list of proposal statuses to apply as a filter. * - version: A comma-separated list of Swift version numbers to apply as a filter. * - search: Raw, URL-encoded text used to filter by individual term. * * @param {string} fragment - A URI fragment to use as the basis for a search. */ function _applyFragment (fragment) { if (!fragment || fragment.substr(0, 2) !== '#?') return fragment = fragment.substring(2) // remove the #? // use this literal's keys as the source of truth for key-value pairs in the fragment var actions = { proposal: [], search: null, status: [], version: [] } // parse the fragment as a query string Object.keys(actions).forEach(function (action) { var pattern = new RegExp(action + '=([^=]+)(&|$)') var values = fragment.match(pattern) if (values) { var value = values[1] // 1st capture group from the RegExp if (action === 'search') { value = decodeURIComponent(value) } else { value = value.split(',') } actions[action] = value } }) // perform key-specific parsing and checks if (actions.proposal.length) { document.querySelector('#search-filter').value = actions.proposal.join(',') } else if (actions.search) { document.querySelector('#search-filter').value = actions.search } if (actions.version.length) { var versionSelections = actions.version.map(function (version) { return document.querySelector('#filter-by-swift-' + _idSafeName(version)) }).filter(function (version) { return !!version }) versionSelections.forEach(function (versionSelection) { versionSelection.checked = true }) if (versionSelections.length) { document.querySelector( '#filter-by-' + states['.implemented'].className ).checked = true } } // track this state specifically for toggling the version panel var implementedSelected = false // update the filter selections in the nav if (actions.status.length) { var statusSelections = actions.status.map(function (status) { var stateName = Object.keys(states).filter(function (state) { return states[state].className === status })[0] if (!stateName) return // fragment contains a nonexistent state var state = states[stateName] if (stateName === '.implemented') implementedSelected = true return document.querySelector('#filter-by-' + state.className) }).filter(function (status) { return !!status }) statusSelections.forEach(function (statusSelection) { statusSelection.checked = true }) } // the version panel needs to be activated if any are specified if (actions.version.length || implementedSelected) { ;['#version-options', '#version-options-label'].forEach(function (selector) { document.querySelector('.filter-options') .querySelector(selector).classList .toggle('hidden') }) } // specifying any filter in the fragment should activate the filters in the UI if (actions.version.length || actions.status.length) { toggleFilterPanel() toggleFiltering() } filterProposals() } /** * Writes out the current search and filter settings to document.location * via window.replaceState. */ function _updateURIFragment () { var actions = { proposal: [], search: null, status: [], version: [] } var search = document.querySelector('#search-filter') if (search.value && search.value.match(/(SE-\d\d\d\d)($|((,SE-\d\d\d\d)+))/i)) { actions.proposal = search.value.toUpperCase().split(',') } else { actions.search = search.value } var selectedVersions = document.querySelectorAll('.filter-by-swift-version:checked') var versions = [].map.call(selectedVersions, function (checkbox) { return checkbox.value.split('swift-swift-')[1].split('-').join('.') }) actions.version = versions var selectedStatuses = document.querySelectorAll('.filtered-by-status:checked') var statuses = [].map.call(selectedStatuses, function (checkbox) { var className = checkbox.value var correspondingStatus = Object.keys(states).filter(function (status) { if (states[status].className === className) return true return false })[0] return states[correspondingStatus].className }) // .implemented is redundant if any specific implementation versions are selected. if (actions.version.length) { statuses = statuses.filter(function (status) { return status !== states['.implemented'].className }) } actions.status = statuses // build the actual fragment string. var fragments = [] if (actions.proposal.length) fragments.push('proposal=' + actions.proposal.join(',')) if (actions.status.length) fragments.push('status=' + actions.status.join(',')) if (actions.version.length) fragments.push('version=' + actions.version.join(',')) // encoding the search lets you search for `??` and other edge cases. if (actions.search) fragments.push('search=' + encodeURIComponent(actions.search)) if (!fragments.length) { window.history.replaceState(null, null, './') return } var fragment = '#?' + fragments.join('&') // avoid creating new history entries each time a search or filter updates window.history.replaceState(null, null, fragment) } /** Helper to give versions like 3.0.1 an okay ID to use in a DOM element. (swift-3-0-1) */ function _idSafeName (name) { return 'swift-' + name.replace(/\./g, '-') } /** * Changes the text after 'Filtered by: ' to reflect the current status filters. * * After FILTER_DESCRIPTION_LIMIT filters are explicitly named, start combining the descriptive text * to just state the number of status filters taking effect, not what they are. * * @param {string[]} selectedStateNames - CSS class names corresponding to which statuses were selected. * Populated from the global `stateNames` array. */ function updateFilterDescription (selectedStateNames) { var FILTER_DESCRIPTION_LIMIT = 2 var stateCount = selectedStateNames.length // Limit the length of filter text on small screens. if (window.matchMedia('(max-width: 414px)').matches) { FILTER_DESCRIPTION_LIMIT = 1 } var container = document.querySelector('.toggle-filter-panel') // modify the state names to clump together Implemented with version names var swiftVersionStates = selectedStateNames.filter(function (state) { return state.match(/swift/i) }) if (swiftVersionStates.length > 0 && swiftVersionStates.length <= FILTER_DESCRIPTION_LIMIT) { selectedStateNames = selectedStateNames.filter(function (state) { return !state.match(/swift|implemented/i) }) .concat('Implemented (' + swiftVersionStates.join(', ') + ')') } if (selectedStateNames.length > FILTER_DESCRIPTION_LIMIT) { container.innerText = stateCount + ' Filters' } else if (selectedStateNames.length === 0) { container.innerText = 'All Statuses' } else { selectedStateNames = selectedStateNames.map(cleanNumberFromState) container.innerText = selectedStateNames.join(' or ') } } /** Updates the `${n} Proposals` display just above the proposals list. */ function updateProposalsCount (count) { var numberField = document.querySelector('#proposals-count-number') numberField.innerText = (count.toString() + ' proposal' + (count !== 1 ? 's' : '')) } function updateFilterStatus () { var labels = [].concat.apply([], document.querySelectorAll('#filter-options label')) labels.forEach(function (label) { var count = states[label.getAttribute('data-state-key')].count var cleanedLabel = cleanNumberFromState(label.innerText) label.innerText = addNumberToState(cleanedLabel, count) }) } function cleanNumberFromState (state) { return state.replace(/ *\([^)]*\) */g, '') } function addNumberToState (state, count) { return state + ' (' + count + ')' }