-
Notifications
You must be signed in to change notification settings - Fork 19
Changelog: group entries by release, replace pagination with scroll and search #5590
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
0ab357f
changelog: record the release each entry ships in
dimitrieh 7435a2c
changelog: group the listing by release, retire pagination
dimitrieh b1df900
changelog: size the listing heading
dimitrieh 9526247
changelog: use Algolia for search, make release labels linkable
dimitrieh d65736b
changelog: make the release deep link independent of arrival order
dimitrieh 41d391c
Merge branch 'main' into changelog/release-rail
dimitrieh 7cdf7a0
changelog: take the release anchor from the router too
dimitrieh 949bd77
Merge remote-tracking branch 'origin/main' into changelog/release-rail
dimitrieh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,29 +1,141 @@ | ||
| <script setup lang="ts"> | ||
| const props = defineProps<{ | ||
| page: number | ||
| }>() | ||
|
|
||
| const { entries, totalPages } = useChangelogList(props.page) | ||
| const { entries, visibleGroups, hasMore, showMore, revealRelease } = useChangelogList() | ||
|
|
||
| useSeoMeta({ | ||
| title: 'Changelog', | ||
| }) | ||
|
|
||
| // The Atom feed at /changelog/index.xml has always existed but nothing advertised it, | ||
| // so feed readers and browsers could not discover it from the page. | ||
| useHead({ | ||
| link: [{ | ||
| rel: 'alternate', | ||
| type: 'application/atom+xml', | ||
| title: 'FlowFuse Changelog', | ||
| href: '/changelog/index.xml', | ||
| }], | ||
| }) | ||
|
|
||
| // Dots are legal in an id but awkward to select, so "2.33" anchors as "release-2-33". | ||
| const anchorId = (release: string) => `release-${release.replace(/\./g, '-')}` | ||
|
|
||
| // Infinite scroll: a sentinel below the list, which is what IntersectionObserver is | ||
| // actually good at. | ||
| const sentinel = ref<HTMLElement | null>(null) | ||
| let observer: IntersectionObserver | null = null | ||
|
|
||
| // A release anchor shared from this page can name a release that is not rendered yet, | ||
| // since the list starts at the newest entries only. Reveal it before the browser is | ||
| // asked to scroll, otherwise the link lands at the top of the page. | ||
| const route = useRoute() | ||
|
|
||
| // The hash this already acted on, so repeat triggers are ignored but a later hash (back | ||
| // and forward, or a second link) still resolves. A boolean latch would swallow those. | ||
| let resolvedHash: string | null = null | ||
|
|
||
| async function revealFromHash () { | ||
| if (!import.meta.client) return | ||
| // Neither source is the truth on its own. A plain in-page anchor click is handled by the | ||
| // browser without going through the router, so route.hash lags behind the address bar. | ||
| // On a cold load it is the other way round: the router already carries the hash while | ||
| // window.location has not been restored yet, which is when this used to give up. | ||
| const id = (window.location.hash || route.hash).replace(/^#/, '') | ||
| if (!id || id === resolvedHash) return | ||
| // Matched against the releases that exist rather than by reversing anchorId, so a | ||
| // hash belonging to anything else on the page is left for the browser to handle. | ||
| const release = entries.value.find(e => anchorId(e.release) === id)?.release | ||
| if (!release || !revealRelease(release)) return | ||
| resolvedHash = id | ||
| await nextTick() | ||
| // `instant` overrides the site-wide `scroll-behavior: smooth`. Arriving at an old | ||
| // release is a jump of tens of thousands of pixels, which is not worth animating. | ||
| document.getElementById(id)?.scrollIntoView({ behavior: 'instant' }) | ||
| } | ||
|
|
||
| // The hash and the archive can land in either order: the archive is in the payload on the | ||
| // prerendered page but still in flight in dev and on a client-side navigation, and the hash | ||
| // changes on its own for back and forward. Retry on whichever moves rather than assuming | ||
| // both are ready at mount, since the reveal only works once the entries exist. | ||
| watch([entries, () => route.hash], () => { revealFromHash() }) | ||
|
|
||
| onMounted(() => { | ||
| observer = new IntersectionObserver((records) => { | ||
| if (records.some(r => r.isIntersecting) && hasMore.value) showMore() | ||
| }, { rootMargin: '600px 0px' }) | ||
| if (sentinel.value) observer.observe(sentinel.value) | ||
| revealFromHash() | ||
| window.addEventListener('hashchange', revealFromHash) | ||
| }) | ||
|
|
||
| onUnmounted(() => { | ||
| observer?.disconnect() | ||
| if (import.meta.client) window.removeEventListener('hashchange', revealFromHash) | ||
| }) | ||
| </script> | ||
|
|
||
| <template> | ||
| <div class="ff-blog container m-auto text-left max-w-4xl pt-8 pb-24 w-full px-6"> | ||
| <div class="w-full sm:flex justify-between"> | ||
| <h1 class="mb-0">Changelog</h1> | ||
| <div class="flex flex-row max-sm:mt-4 justify-between sm:justify-end gap-4 items-end"> | ||
| <p class="my-0">Getting all the news on new features we ship</p> | ||
| <a href="/changelog/index.xml" class="mb-2 hover:text-blue-800 hover:cursor-pointer" title="View the changelog RSS feed"> | ||
| <UIcon name="i-heroicons-rss" class="w-5 h-5" /> | ||
| <div class="ff-blog container m-auto text-left max-w-6xl pt-8 pb-24 w-full px-6"> | ||
| <div class="w-full sm:flex sm:items-end sm:justify-between gap-4"> | ||
| <div> | ||
| <!-- Sized explicitly: .ff-blog leaves h1 at 16px/400, identical to the subtitle | ||
| below it, so the heading does not read as one. --> | ||
| <h1 class="mb-0 text-2xl font-medium">What's new</h1> | ||
| <p class="my-0 text-gray-500">Every feature, improvement and fix we ship, newest first.</p> | ||
| </div> | ||
| <!-- Search runs on the same Algolia index as the blog, docs and handbook, filtered | ||
| to this section, so it reaches entry bodies and tolerates typos. --> | ||
| <div class="flex flex-row items-center gap-3 max-sm:mt-4 sm:w-80"> | ||
| <a | ||
| href="/changelog/index.xml" | ||
| class="inline-flex items-center gap-1.5 whitespace-nowrap text-sm text-gray-500 hover:text-indigo-600" | ||
| title="Subscribe to the changelog feed" | ||
| > | ||
| <UIcon name="i-heroicons-rss" class="w-4 h-4" /> | ||
| <span>RSS</span> | ||
| </a> | ||
| <AlgoliaSearch index-filter="category:changelog" placeholder="Search the changelog" source-id="changelog" /> | ||
| </div> | ||
| </div> | ||
| <ul class="flex flex-wrap border-t"> | ||
| <ChangelogListItem v-for="entry in entries" :key="entry.path" :entry="entry" /> | ||
| </ul> | ||
| <Pagination base-path="/changelog" :page="page" :total-pages="totalPages" /> | ||
|
|
||
| <div class="mt-8"> | ||
| <!-- The release label lives in a column beside its own entries rather than in a | ||
| separate rail, so it lines up with the entries it belongs to, and sticks while | ||
| you read through them. Two columns per release, not one list plus one nav. --> | ||
| <section | ||
| v-for="group in visibleGroups" | ||
| :id="anchorId(group.release)" | ||
| :key="group.release" | ||
| class="flex scroll-mt-24" | ||
| > | ||
| <!-- pt-7 so the label lines up with the first entry's title rather than sitting | ||
| above it: each entry carries `my-2 py-6` before its title, which this column | ||
| would otherwise not share. The spine border sits on the inner element so it | ||
| starts level with the label, rather than leaving a stub hanging above it. --> | ||
| <div class="hidden lg:block w-36 shrink-0 pt-7"> | ||
| <div class="h-full border-l border-gray-200"> | ||
| <!-- top-16 (64px), not further down: the site header (.ff-header) ends at | ||
| 56px, so anything lower leaves a gap of bare spine above the label. | ||
| The label is the release's own anchor, so it can be linked to directly. --> | ||
| <a | ||
| :href="`#${anchorId(group.release)}`" | ||
| class="sticky top-16 block -ml-px border-l-2 border-indigo-600 pl-5 py-1 font-medium leading-tight text-indigo-600 hover:underline" | ||
| >Release {{ group.release }}</a> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="flex-1 min-w-0 lg:pl-12"> | ||
| <!-- The label column is hidden below lg, so the release still needs naming. --> | ||
| <h2 class="lg:hidden mt-6 mb-0 text-base font-medium"> | ||
| <a :href="`#${anchorId(group.release)}`" class="text-indigo-600 hover:underline">Release {{ group.release }}</a> | ||
| </h2> | ||
| <ul class="flex flex-wrap"> | ||
| <ChangelogListItem v-for="entry in group.entries" :key="entry.path" :entry="entry" /> | ||
| </ul> | ||
| </div> | ||
| </section> | ||
|
|
||
| <div ref="sentinel" aria-hidden="true" class="h-px" /> | ||
| <p v-if="hasMore" class="py-6 text-center text-sm text-gray-400">Loading more…</p> | ||
| </div> | ||
| </div> | ||
| </template> | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,17 +1,67 @@ | ||
| export const CHANGELOG_PAGE_SIZE = 19 | ||
| // How many entries are in the markup before any scrolling. The whole archive is | ||
| // fetched in one query either way (it always was - the old paginated page fetched | ||
| // everything and sliced it), so this only governs how much is rendered up front. | ||
| export const CHANGELOG_INITIAL_VISIBLE = 20 | ||
| export const CHANGELOG_VISIBLE_STEP = 20 | ||
|
|
||
| export function useChangelogList(pageNumber: number) { | ||
| export interface ChangelogEntry { | ||
| path: string | ||
| title: string | ||
| description?: string | ||
| date: string | ||
| release: string | ||
| authors?: string[] | ||
| } | ||
|
|
||
| export interface ChangelogReleaseGroup { | ||
| release: string | ||
| entries: ChangelogEntry[] | ||
| } | ||
|
|
||
| export function useChangelogList () { | ||
| const { data: allEntries } = useAsyncData( | ||
| 'changelog-all', | ||
| () => queryCollection('changelog').order('date', 'DESC').all() | ||
| ) | ||
|
|
||
| const totalPages = computed(() => Math.max(1, Math.ceil((allEntries.value || []).length / CHANGELOG_PAGE_SIZE))) | ||
| const visibleCount = ref(CHANGELOG_INITIAL_VISIBLE) | ||
|
|
||
| const entries = computed(() => (allEntries.value || []) as unknown as ChangelogEntry[]) | ||
|
|
||
| const visible = computed(() => entries.value.slice(0, visibleCount.value)) | ||
| const hasMore = computed(() => visibleCount.value < entries.value.length) | ||
|
|
||
| function groupByRelease (list: ChangelogEntry[]): ChangelogReleaseGroup[] { | ||
| const groups: ChangelogReleaseGroup[] = [] | ||
| for (const entry of list) { | ||
| const last = groups.at(-1) | ||
| // The list is already date-ordered and releases ship in date order, so | ||
| // consecutive runs are exactly the groups. | ||
| if (last && last.release === entry.release) last.entries.push(entry) | ||
| else groups.push({ release: entry.release, entries: [entry] }) | ||
| } | ||
| return groups | ||
| } | ||
|
|
||
| /** Groups actually rendered right now. */ | ||
| const visibleGroups = computed(() => groupByRelease(visible.value)) | ||
|
|
||
| function showMore () { | ||
| visibleCount.value = Math.min(visibleCount.value + CHANGELOG_VISIBLE_STEP, entries.value.length) | ||
| } | ||
|
|
||
| const entries = computed(() => { | ||
| const start = (pageNumber - 1) * CHANGELOG_PAGE_SIZE | ||
| return (allEntries.value || []).slice(start, start + CHANGELOG_PAGE_SIZE) | ||
| }) | ||
| /** | ||
| * Renders through the end of `release` so that /changelog/#release-2-20 resolves on | ||
| * arrival: only the newest entries are in the markup, so an older release's anchor | ||
| * does not exist until its entries are rendered. Returns false for an unknown release. | ||
| */ | ||
| function revealRelease (release: string): boolean { | ||
| // Releases ship in date order and the list is date-ordered, so the run is contiguous. | ||
| const last = entries.value.findLastIndex(e => e.release === release) | ||
| if (last < 0) return false | ||
| visibleCount.value = Math.max(visibleCount.value, last + 1) | ||
| return true | ||
| } | ||
|
|
||
| return { entries, totalPages } | ||
| return { entries, visibleGroups, hasMore, showMore, revealRelease } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While going through the deploy preview, I was expecting these to be interactive, actual shareable anchors. Was it deliberate to make them static? Is it related to the infinite scroll choice?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not deliberate. The ids were there but nothing linked to them. The sticky label and the mobile heading are both anchors now.
It was related to the scroll choice though. A link like
/changelog/#release-2-20had nothing to scroll to on arrival, since only the newest entries are rendered. The listing now renders through that release first, then scrolls to it. Tested back to#release-1-12, the oldest one.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tested this on the deploy preview with
/changelog/#release-1-12, loading it fresh doesn't scroll anywhere, it stays at the top (newest release).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You were right, cold load was broken. My test only covered a page that was already loaded, which is why it passed.
At hydration
window.location.hashis still empty. Nuxt keeps the hash on the route and restores the address bar only once the initial navigation settles, sorevealFromHashgave up before it found the release, and nothing changed afterwards to make it retry.One line: read whichever of the two is populated.
Verified on a local production build. Cold
#release-1-12and#release-2-20both render through and land on the release, no hash is unchanged, and label clicks, in-page hash changes and browser back all still work.Could you re-test on the preview once it redeploys?