Skip to content

Commit

Permalink
Allow sorting alphabetically and chronologically
Browse files Browse the repository at this point in the history
  • Loading branch information
holly-cummins committed Feb 28, 2024
1 parent b8a3069 commit aef5849
Show file tree
Hide file tree
Showing 8 changed files with 300 additions and 49 deletions.
9 changes: 4 additions & 5 deletions src/components/extension-card.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,11 @@ import ExtensionImage from "./extension-image"
const Card = styled(props => <Link {...props} />)`
font-size: 3.5em;
text-align: center;
margin: 15px;
padding: 1rem;
width: 100%;
background: var(--white) 0 0 no-repeat padding-box;
border: ${props =>
props.$unlisted ? "1px solid var(--grey-0)" : "1px solid var(--grey-1)"};
props.$unlisted ? "1px solid var(--grey-0)" : "1px solid var(--grey-1)"};
border-radius: 10px;
opacity: 1;
display: flex;
Expand Down Expand Up @@ -141,9 +140,9 @@ const ExtensionCard = ({ extension }) => {
{extension.metadata.maven?.timestamp &&
isValid(+extension.metadata.maven?.timestamp)
? `Publish Date: ${format(
new Date(+extension.metadata.maven.timestamp),
"MMM dd, yyyy"
)}`
new Date(+extension.metadata.maven.timestamp),
"MMM dd, yyyy"
)}`
: spacer}
</ExtensionInfo>
</FinerDetails>
Expand Down
64 changes: 38 additions & 26 deletions src/components/extensions-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import { useState } from "react"
import Filters from "./filters/filters"
import ExtensionCard from "./extension-card"
import styled from "styled-components"
import { extensionComparator } from "./util/extension-comparator"
import { timestampExtensionComparator } from "./sortings/timestamp-extension-comparator"
import Sortings from "./sortings/sortings"

const FilterableList = styled.div`
margin-left: var(--site-margins);
margin-right: var(--site-margins);
margin-top: 85px;
display: flex;
flex-direction: row;
justify-content: space-between;
Expand All @@ -17,9 +17,9 @@ const FilterableList = styled.div`
const Extensions = styled.ol`
list-style: none;
display: grid;
gap: 30px;
grid-template-columns: repeat(auto-fill, minmax(260px, auto));
grid-template-rows: repeat(auto-fill, 1fr);
width: 100%;
`

const CardItem = styled.li`
Expand All @@ -28,6 +28,14 @@ const CardItem = styled.li`
display: flex;
`

const InfoSortRow = styled.div`
margin-top: 85px;
padding-left: var(--site-margins);
padding-right: var(--site-margins);
display: flex;
justify-content: space-between;
`

const RightColumn = styled.div`
display: flex;
flex-direction: column;
Expand All @@ -37,20 +45,20 @@ const RightColumn = styled.div`

const ExtensionCount = styled.h2`
margin-top: 1.25rem;
margin-left: 3.25rem;
margin-bottom: 0.5rem;
width: 100%;
font-size: 1.25rem;
font-size: 1rem;
font-weight: 400;
font-style: italic;
`

const ExtensionsList = ({ extensions, categories }) => {
// Do some pre-filtering for content we will never want, like superseded extensions
const allExtensions = extensions.filter(extension => !extension.isSuperseded)

const [filteredExtensions, setExtensions] = useState(allExtensions)
const [extensionComparator, setExtensionComparator] = useState(() => timestampExtensionComparator)

// TODO why is this guard necessary?
if (allExtensions) {
// Exclude unlisted extensions from the count, even though we sometimes show them if there's a direct search for it
const extensionCount = allExtensions.filter(
Expand All @@ -65,26 +73,30 @@ const ExtensionsList = ({ extensions, categories }) => {
: `Showing ${filteredExtensions.length} matching of ${extensionCount} extensions`

return (
<FilterableList className="extensions-list">
<Filters
extensions={allExtensions}
categories={categories}
filterAction={setExtensions}
/>
<RightColumn>
{" "}
<ExtensionCount>{countMessage}</ExtensionCount>
<Extensions>
{filteredExtensions.map(extension => {
return (
<CardItem key={extension.id}>
<ExtensionCard extension={extension} />
</CardItem>
)
})}
</Extensions>{" "}
</RightColumn>
</FilterableList>
<div>
<InfoSortRow><ExtensionCount>{countMessage}</ExtensionCount>
<Sortings sorterAction={setExtensionComparator}></Sortings>
</InfoSortRow>
<FilterableList className="extensions-list">
<Filters
extensions={allExtensions}
categories={categories}
filterAction={setExtensions}
/>
<RightColumn>
{" "}
<Extensions>
{filteredExtensions.map(extension => {
return (
<CardItem key={extension.id}>
<ExtensionCard extension={extension} />
</CardItem>
)
})}
</Extensions>{" "}
</RightColumn>
</FilterableList>
</div>
)
} else {
return (
Expand Down
46 changes: 46 additions & 0 deletions src/components/sortings/alphabetical-extension-comparator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
const HOURS_IN_MS = 60 * 60 * 1000

function compareByTimestamp(a, b) {
const timestampA = roundToTheNearestHour(a?.metadata?.maven?.timestamp)
const timestampB = roundToTheNearestHour(b?.metadata?.maven?.timestamp)

if (timestampA && timestampB) {
const delta = timestampB - timestampA
if (delta === 0) {
return compareAlphabetically(a, b)
} else {
return delta
}
} else if (timestampA) {
return -1
} else if (timestampB) {
return 1
}
}

const alphabeticalExtensionComparator = (a, b) => {

let comp = compareAlphabetically(a, b)
if (comp === 0) {
comp = compareByTimestamp(a, b)
}
return comp
}

function roundToTheNearestHour(n) {
if (n) {
return Math.round(n / HOURS_IN_MS) * HOURS_IN_MS
}
}

function compareAlphabetically(a, b) {
if (a.sortableName) {
return a.sortableName.localeCompare(b.sortableName)
} else if (b.sortableName) {
return 1
} else {
return 0
}
}

module.exports = { alphabeticalExtensionComparator }
28 changes: 28 additions & 0 deletions src/components/sortings/alphabetical-extension-comparator.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { alphabeticalExtensionComparator } from "./alphabetical-extension-comparator"

describe("the alphabetical extension comparator", () => {
it("sorts by date when the names are identical", () => {
const a = { sortableName: "alpha", metadata: { maven: { timestamp: 1795044005 } } }
const b = { sortableName: "alpha", metadata: { maven: { timestamp: 1695044005 } } }

expect(alphabeticalExtensionComparator(a, b)).toBeLessThan(0)
expect(alphabeticalExtensionComparator(b, a)).toBeGreaterThan(0)
})

it("put extensions with a name ahead of those without", () => {
const a = { sortableName: "alpha" }
const b = {}

expect(alphabeticalExtensionComparator(a, b)).toBe(-1)
expect(alphabeticalExtensionComparator(b, a)).toBe(1)
})

it("sorts alphabetically", () => {
const a = { sortableName: "alpha", metadata: { maven: { timestamp: 1795044005 } } }
const b = { sortableName: "beta", metadata: { maven: { timestamp: 1695044005 } } }

expect(alphabeticalExtensionComparator(a, b)).toBeLessThan(0)
expect(alphabeticalExtensionComparator(b, a)).toBeGreaterThan(0)
})

})
90 changes: 90 additions & 0 deletions src/components/sortings/sortings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import * as React from "react"
import styled from "styled-components"
import Select from "react-select"
import { styles } from "../util/styles/style"
import { timestampExtensionComparator } from "./timestamp-extension-comparator"
import { alphabeticalExtensionComparator } from "./alphabetical-extension-comparator"

const Title = styled.label`
font-size: var(--font-size-16);
letter-spacing: 0;
color: var(--grey-2);
width: 100px;
text-align: right;
`

const SortBar = styled.div`
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: var(--a-small-space);
`

const Element = styled.form`
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: 16px;
`

// Grab CSS variables in javascript
const grey = styles["grey-2"]

const colourStyles = {
control: styles => ({
...styles,
borderRadius: 0,
color: grey,
borderColor: grey,
width: "280px",
}),
option: (styles, { isDisabled }) => {
return {
...styles,
cursor: isDisabled ? "not-allowed" : "default",
borderRadius: 0,
}
},
dropdownIndicator: styles => ({
...styles,
color: grey, // Custom colour
}),
indicatorSeparator: styles => ({
...styles,
margin: 0,
backgroundColor: grey,
}),
}

const sortings = [
{ label: "most recently released", value: "time", comparator: timestampExtensionComparator },
{ label: "alphabetical", value: "alpha", comparator: alphabeticalExtensionComparator }]

const Sortings = ({ sorterAction }) => {

const setSortByDescription = (entry) => {
// We need to wrap our comparator functions in functions or they get called, which goes very badly
sorterAction && sorterAction(() => entry.comparator)
}

return (
<SortBar className="sortings">
<Title htmlFor="sort">Sort by</Title>
<Element data-testid="sort-form">
<Select
placeholder="default"
options={sortings}
onChange={label => setSortByDescription(label)}
name="sort"
inputId="sort"
styles={colourStyles}
/>
</Element>
</SortBar>
)
}

export default Sortings

76 changes: 76 additions & 0 deletions src/components/sortings/sortings.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React from "react"
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import Sortings from "./sortings"
import selectEvent from "react-select-event"
import { alphabeticalExtensionComparator } from "./alphabetical-extension-comparator"
import { timestampExtensionComparator } from "./timestamp-extension-comparator"


let mockQueryParamSearchString = undefined

jest.mock("react-use-query-param-string", () => {

const original = jest.requireActual("react-use-query-param-string")
return {
...original,
useQueryParamString: jest.fn().mockImplementation(() => [mockQueryParamSearchString, jest.fn().mockImplementation((val) => mockQueryParamSearchString = val), true]),
getQueryParams: jest.fn().mockReturnValue({ "search-regex": mockQueryParamSearchString })

}
})

describe("sorting bar", () => {
const sortListener = jest.fn()

beforeEach(() => {
mockQueryParamSearchString = undefined
render(
<Sortings
sorterAction={sortListener}
/>
)
})

afterEach(() => {
jest.clearAllMocks()
})


describe("sorting", () => {
userEvent.setup()
const label = "Sort by"

it("lets the listener know when a new sort scheme is chosen", async () => {
expect(screen.getByTestId("sort-form")).toHaveFormValues({
"sort": "",
})
await selectEvent.select(screen.getByLabelText(label), "most recently released")

expect(sortListener).toHaveBeenCalled()
})

it("lets the listener know when a new timestamp sort scheme is chosen", async () => {
expect(screen.getByTestId("sort-form")).toHaveFormValues({
"sort": "",
})
await selectEvent.select(screen.getByLabelText(label), "most recently released")

expect(sortListener).toHaveBeenCalledWith(expect.any(Function))
const param = sortListener.mock.calls[0][0]
expect(param()).toEqual(timestampExtensionComparator)
})

it("lets the listener know when an alphabetical scheme is chosen", async () => {
expect(screen.getByTestId("sort-form")).toHaveFormValues({
"sort": "",
})
await selectEvent.select(screen.getByLabelText(label), "alphabetical")

expect(sortListener).toHaveBeenCalledWith(expect.any(Function))
const param = sortListener.mock.calls[0][0]
expect(param()).toEqual(alphabeticalExtensionComparator)
})

})
})
Loading

0 comments on commit aef5849

Please sign in to comment.