Skip to content

Commit

Permalink
Refactor frontend main page and badge-example code (#2441)
Browse files Browse the repository at this point in the history
- The goal of this PR is:
    - Consume the new service-definition format. (#2397)
    - Make the frontend more readable.
- Behavior changes:
    - I changed the **Image** field in the markup modal to show only the path.
    - I added another click-to-select field below that shows the complete URL.
    - This made it easier to suppress the live badge preview while it contains placeholders like `:user` or `:gem`, a minor tweak discussed at #2427 (comment).
    - The search box now searches all categories, regardless of the current page. (This is an improvement, I would say.)
- I did not deliberately address performance, though I ripped out a bunch of anonymous functions and avoided re-filtering all the examples by category on every render, which I expect will not hurt. I haven't really tested this on a mobile connection and it'd be worth doing that.
- It would be great to have some tests of the components, though getting started with that seemed like a big project and I did not want to make this any larger than it already is.

It's a medium-sized refactor:

1. Replace `BadgeExamples`, `Category` and `Badge` component with a completely rewritten `BadgeExamples` component which renders a table of badges, and `CategoryHeading` and `CategoryHeadings` components.
2. Refactor `ExamplesPage` and `SearchResults` components into a new `Main` component.
3. Rewrite the data flow for `MarkupModal`. Rather than rely on unmounting and remounting the component to copy the badge URL into state, employ the `getDerivedStateFromProps` lifecycle method.
4. Remove `prepareExamples` and `all-badge-examples`.
5. Rewrite the `$suggest` schema to harmonize with the service definition format. It's not backward-compatible which means at deploy time there probably will be 10–20 minutes of downtime on that feature, between the first server deploy and the final gh-pages deploy.  🤷‍♂️ (We could leave the old version in place if it seems worth it.)
6. Added two new functions in `make-badge-url` with tests. I removed _most_ of the uses of the old functions, but there are some in parts of the frontend I didn't touch like the static and dynamic badge generators, and again I didn't want to make this any larger than it already is.
7. Fix a couple bugs in the service-definition export.
  • Loading branch information
paulmelnikow committed Dec 8, 2018
1 parent 8a8311d commit 58b2765
Show file tree
Hide file tree
Showing 33 changed files with 754 additions and 996 deletions.
2 changes: 1 addition & 1 deletion .eslintrc-frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ settings:
rules:
no-console: "error"

import/extensions: ["error", "never", { "json": "always" }]
import/extensions: ["error", "never", { "json": "always", "yml": "always" }]
3 changes: 3 additions & 0 deletions doc/TUTORIAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,9 @@ module.exports = class GemVersion extends BaseJsonService {

Save, run `npm start`, and you can see it [locally](http://127.0.0.1:3000/).

If you update `examples`, you don't have to restart the server. Run `npm run
defs` in another terminal window and the frontend will update.

### (4.5) Write Tests <!-- Change the link below when you change the heading -->
[write tests]: #45-write-tests

Expand Down
205 changes: 74 additions & 131 deletions frontend/components/badge-examples.js
Original file line number Diff line number Diff line change
@@ -1,149 +1,92 @@
import React from 'react'
import { Link } from 'react-router-dom'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import resolveBadgeUrl from '../lib/badge-url'
import { badgeUrlFromPath, staticBadgeUrl } from '../../lib/make-badge-url'

const Badge = ({
title,
exampleUrl,
previewUrl,
urlPattern,
documentation,
baseUrl,
longCache,
shouldDisplay = () => true,
onClick,
}) => {
const handleClick = onClick
? () =>
onClick({
title,
exampleUrl,
previewUrl,
urlPattern,
documentation,
})
: undefined
export default class BadgeExamples extends React.Component {
static propTypes = {
definitions: PropTypes.array.isRequired,
baseUrl: PropTypes.string,
longCache: PropTypes.bool.isRequired,
onClick: PropTypes.func.isRequired,
}

renderExample(exampleData) {
const { baseUrl, longCache, onClick } = this.props
const { title, example, preview } = exampleData

let previewUrl
// There are two alternatives for `preview`. Refer to the schema in
// `services/service-definitions.js`.
if (preview.label !== undefined) {
const { label, message, color } = preview
previewUrl = staticBadgeUrl({ baseUrl, label, message, color })
} else {
const { path, queryParams } = preview
previewUrl = badgeUrlFromPath({ baseUrl, path, queryParams, longCache })
}

// There are two alternatives for `example`. Refer to the schema in
// `services/service-definitions.js`.
let exampleUrl
if (example.pattern !== undefined) {
const { pattern, namedParams, queryParams } = example
exampleUrl = badgeUrlFromPath({
baseUrl,
path: pattern,
namedParams,
queryParams,
})
} else {
const { path, queryParams } = example
exampleUrl = badgeUrlFromPath({ baseUrl, path, queryParams })
}

const key = `${title} ${previewUrl} ${exampleUrl}`

const previewImage = previewUrl ? (
<img
className={classNames('badge-img', { clickable: onClick })}
onClick={handleClick}
src={resolveBadgeUrl(previewUrl, baseUrl, { longCache })}
alt=""
/>
) : (
'\u00a0'
) // non-breaking space
const resolvedExampleUrl = resolveBadgeUrl(
urlPattern || previewUrl,
baseUrl,
{ longCache: false }
)
const handleClick = () => onClick(exampleData)

if (shouldDisplay()) {
return (
<tr>
<th
className={classNames({ clickable: onClick })}
onClick={handleClick}
>
<tr key={key}>
<th className="clickable" onClick={handleClick}>
{title}:
</th>
<td>{previewImage}</td>
<td>
<code
className={classNames({ clickable: onClick })}
<img
className="badge-img clickable"
onClick={handleClick}
>
{resolvedExampleUrl}
src={previewUrl}
alt=""
/>
</td>
<td>
<code className="clickable" onClick={handleClick}>
{exampleUrl}
</code>
</td>
</tr>
)
}
return null
}
Badge.propTypes = {
title: PropTypes.string.isRequired,
exampleUrl: PropTypes.string,
previewUrl: PropTypes.string,
urlPattern: PropTypes.string,
documentation: PropTypes.string,
baseUrl: PropTypes.string,
longCache: PropTypes.bool.isRequired,
shouldDisplay: PropTypes.func,
onClick: PropTypes.func.isRequired,
}

const Category = ({ category, examples, baseUrl, longCache, onClick }) => {
if (examples.filter(example => example.shouldDisplay()).length === 0) {
return null
}
return (
<div>
<Link to={`/examples/${category.id}`}>
<h3 id={category.id}>{category.name}</h3>
</Link>
<table className="badge">
<tbody>
{examples.map(badgeData => (
<Badge
key={badgeData.key}
{...badgeData}
baseUrl={baseUrl}
longCache={longCache}
onClick={onClick}
/>
))}
</tbody>
</table>
</div>
)
}
Category.propTypes = {
category: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}).isRequired,
examples: PropTypes.arrayOf(
PropTypes.shape({
title: PropTypes.string.isRequired,
exampleUrl: PropTypes.string,
previewUrl: PropTypes.string,
urlPattern: PropTypes.string,
documentation: PropTypes.string,
})
).isRequired,
baseUrl: PropTypes.string,
longCache: PropTypes.bool.isRequired,
onClick: PropTypes.func.isRequired,
}
render() {
const { definitions } = this.props

const BadgeExamples = ({ categories, baseUrl, longCache, onClick }) => (
<div>
{categories.map((categoryData, i) => (
<Category
key={i}
{...categoryData}
baseUrl={baseUrl}
longCache={longCache}
onClick={onClick}
/>
))}
</div>
)
BadgeExamples.propTypes = {
categories: PropTypes.arrayOf(
PropTypes.shape({
category: Category.propTypes.category,
examples: Category.propTypes.examples,
})
),
baseUrl: PropTypes.string,
longCache: PropTypes.bool.isRequired,
onClick: PropTypes.func.isRequired,
}
if (!definitions) {
return null
}

const flattened = definitions.reduce((accum, current) => {
const { examples } = current
return accum.concat(examples)
}, [])

export { Badge, BadgeExamples }
return (
<div>
<table className="badge">
<tbody>
{flattened.map(exampleData => this.renderExample(exampleData))}
</tbody>
</table>
</div>
)
}
}
32 changes: 32 additions & 0 deletions frontend/components/category-headings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Link } from 'react-router-dom'

const CategoryHeading = ({ category }) => {
const { id, name } = category

return (
<Link to={`/examples/${id}`}>
<h3 id={id}>{name}</h3>
</Link>
)
}
CategoryHeading.propTypes = {
category: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}).isRequired,
}

const CategoryHeadings = ({ categories }) => (
<div>
{categories.map(category => (
<CategoryHeading category={category} key={category.id} />
))}
</div>
)
CategoryHeadings.propTypes = {
categories: PropTypes.arrayOf(CategoryHeading.propTypes.category).isRequired,
}

export { CategoryHeading, CategoryHeadings }
109 changes: 0 additions & 109 deletions frontend/components/examples-page.js

This file was deleted.

Loading

0 comments on commit 58b2765

Please sign in to comment.