-
Notifications
You must be signed in to change notification settings - Fork 75
Add collapsible sidebar and redesign app layout #1748
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
Draft
Flo0807
wants to merge
138
commits into
develop
Choose a base branch
from
feature/collapsible-sidebar
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
138 commits
Select commit
Hold shift + click to select a range
25041e2
Implement collapsible sidebar
Flo0807 7fed7c7
Delete BackpexSidebar hook
Flo0807 81bbf2c
Update build
Flo0807 f784fc0
Save sidebar state in local storage
Flo0807 dae6800
Format
Flo0807 f618d27
Update translations
Flo0807 b8a52a3
Update build
Flo0807 c720cb1
Call applyState in updated hook
Flo0807 b99de96
Rename topbar_branding into sidebar_branding
Flo0807 eb1792c
Use CSS variable for sidebar width
Flo0807 44b0e79
Build assets
Flo0807 c9c5488
Add user preference system for UI states
Flo0807 3f1d6b8
Fix tests
Flo0807 6b244a9
Merge branch 'develop' into feature/collapsible-sidebar
Flo0807 ae41454
Fix stale topbar_branding references in template and docs
Flo0807 871c339
Add v0.19 upgrade guide for collapsible sidebar changes
Flo0807 1eb98c8
Add 16rem fallback for --sidebar-width CSS variable
Flo0807 4f77f90
Make sidebar section toggle a keyboard-accessible button
Flo0807 156bd12
Trap focus and restore it for the mobile sidebar drawer
Flo0807 00b45bc
Mark collapsed sidebar as inert
Flo0807 d05bb1d
Render sidebar with responsive defaults to reduce first-paint flash
Flo0807 116517c
Clean up sidebar hook listeners on destroy
Flo0807 d5017f4
Guard sidebar hook when no sidebar slot is rendered
Flo0807 3869b2a
Use single nav landmark for sidebar
Flo0807 edc71b6
Write CSS translate (not transform) to toggle sidebar
Flo0807 979eb4e
Gate sidebar transitions behind motion-safe
Flo0807 a8770d8
Raise sidebar breakpoint from md to lg
Flo0807 6c7ac92
Use daisyUI neutral for sidebar overlay
Flo0807 5ecfabe
Narrow sidebar main transition to margin-left
Flo0807 b0b3ccf
Require explicit id on sidebar_section
Flo0807 0781dad
Allow requestAnimationFrame in standard lint config
Flo0807 b5a7eb0
Track sidebar section handlers in a WeakMap
Flo0807 b37b5d6
Read sidebar breakpoint from --breakpoint-lg
Flo0807 c4852ee
Merge branch 'feature/collapsible-sidebar' into feature/user-preferen…
Flo0807 df0fd36
Route preferences through a pluggable adapter architecture
Flo0807 94f203f
Rewrite user preferences guide for adapter architecture
Flo0807 cd78ea9
Drop module-ref backticks for Backpex.LiveResource.Index in guide
Flo0807 19a669e
Rebuild static JS assets for endpointPath rename
Flo0807 67bf334
Refactor user preferences
Flo0807 8f71c26
Route filter persistence through intent-driven handlers
Flo0807 f0c6fe1
Disambiguate clear-filter selector in preferences persistence test
Flo0807 a0ae584
Preserve sidebar section state across live_redirect
Flo0807 08eecbe
Preserve sidebar open state across live_redirect
Flo0807 4e7376a
Reword sidebar test comments to reflect WIP status
Flo0807 3d84348
Respect persisted empty filters over :default filters
Flo0807 9608b56
Thread socket.assigns through InitAssigns reads via Context
Flo0807 e7ba94d
Add session-preservation helper, document fetch/3, tighten default-vs…
Flo0807 f1d27f9
Add match_fun route pattern to Backpex.Preferences.Router
Flo0807 19040f6
Lift sessionStorage mirroring into BackpexPreferences.get/set
Flo0807 a30c30e
Collapse reject+filter into single filter in best_prefix_match
Flo0807 9694aa5
Rebuild static JS assets for sessionStorage mirror helpers
Flo0807 0b0e4d8
Validate preference keys with opt-in dispatcher guard
Flo0807 d2fda97
Broadcast preference changes on Phoenix.PubSub (opt-in)
Flo0807 6c808ae
Add Backpex.Test helpers for LiveResource tests
Flo0807 b6d0a05
Drop module-ref backticks to hidden Backpex.LiveResource.Index
Flo0807 af2e10a
Delete docs directory
Flo0807 f7b5555
Merge branch 'develop' into feature/collapsible-sidebar
Flo0807 2cd6b31
Document breakpoint shift and translation renames in v0.19 upgrade guide
Flo0807 09850e9
Merge branch 'feature/collapsible-sidebar' into feature/user-preferen…
Flo0807 4f5c74e
Run `mix format`
Flo0807 a504949
Delete pr-1808-analysis.md
Flo0807 0ec782b
Delete pr-description.md
Flo0807 0023fc3
Pass socket and sidebar_open to app_shell in installer template
Flo0807 b4c90e8
Fix async-test race in Backpex.Test helpers test
Flo0807 4606af3
Reduce duplicated setup boilerplate in preference tests
Flo0807 19fdf07
Validate keys in put_batch to honor validate_keys strategy
Flo0807 07be23e
Rename misleading test artifacts in preference suite
Flo0807 5bbc18b
Tighten weak assertions across preference tests
Flo0807 9a160e8
Convert adapter exceptions in put_batch into error tuples
Flo0807 9fc160c
Add direct coverage for apply_effects_on_conn, Context.coerce, and Ro…
Flo0807 939f612
Skip PubSub broadcast and subscribe for unidentified callers
Flo0807 70e5681
Mark main content inert while mobile sidebar drawer is open
Flo0807 dd985c3
Document Router.cookie_path/1 rename in v0.19 upgrade guide
Flo0807 ce0a6a9
Update installation guide route name to backpex_preferences
Flo0807 dc82c1a
Add @moduledoc false to fake test modules to unblock Quokka
Flo0807 c96b517
Mount theme selector hook on the form instead of the dropdown wrapper
Flo0807 a2a1e24
Skip autolink for removed Backpex.Router.cookie_path/1
Flo0807 0023e86
Name match-wildcard variable to satisfy Credo consistency
Flo0807 5e91365
Accept typed values in Backpex.Filters.Range parsing
Flo0807 020a9fe
Return 200 for single-write :unidentified preference writes
Flo0807 02c2f96
Format
Flo0807 5d48fd4
Merge branch 'develop' into feature/collapsible-sidebar
Flo0807 e992460
Add sidebar_menu component to encapsulate the menu wrapper
Flo0807 5e7ec60
Split section expand/collapse into its own hook
Flo0807 f720d22
Drive sidebar visual state from CSS data attributes
Flo0807 ba2e244
Tidy app_shell sidebar checks and guard section setup
Flo0807 0e3b299
Build assets
Flo0807 d99592f
Merge feature/collapsible-sidebar into feature/user-preference-system
Flo0807 9ce2590
Merge branch 'develop' into feature/collapsible-sidebar
Flo0807 6c26d6d
Fix persisted column and metric visibility reverting on live navigation
Flo0807 bcceae7
Fix mobile drawer focus trap when a sidebar section is collapsed
Flo0807 b7a150a
Handle malformed :identity config with a warning instead of a crash
Flo0807 3d285df
Guard get_map/3 against raising preference adapters
Flo0807 cf572d6
Warn when session-stored preferences approach the cookie size limit
Flo0807 4080094
Do not offset app_shell main content without a sidebar slot
Flo0807 769dc9d
Server-render inert on the closed sidebar to keep it out of the pre-h…
Flo0807 9968a41
Group Backpex.Preferences modules in ExDoc
Flo0807 7c11103
Wrap installation guide sidebar examples in sidebar_menu
Flo0807 4450264
Document BackpexPreferencesHook in the v0.20 hook registration advice
Flo0807 354e371
Correct guide: put_batch/3 validates preference keys
Flo0807 bc0baba
Enumerate renamed translation msgids in the v0.20 upgrade guide
Flo0807 a1f997f
Add accessibility assertions to the sidebar browser test
Flo0807 73e4053
Extract column sync mapping into a helper
Flo0807 60534c6
Enable preference persistence on all demo resources
Flo0807 b87920c
Make metrics visibility an opt-in persist key
Flo0807 2b05e3e
Deliver preferences in the connect params so the first render is correct
Flo0807 4bd0aff
Render unacknowledged preference writes on the dead render
Flo0807 08056cf
Scope pending preference writes to the identity that made them
Flo0807 9457afa
Delete Backpex.Preferences.Session
Flo0807 0ee4deb
Drop the unused cookieName and connect_param accessors
Flo0807 7f24d5b
Drop Backpex.Test in favor of Phoenix's follow_redirect/2
Flo0807 df73828
Remove the preferences cross-tab PubSub emitter
Flo0807 1c0ce2f
Remove the opt-in preference key validation gate
Flo0807 eba165c
Remove the unused fetch/3 and parse_key/1 preference reads
Flo0807 65b96b4
Return a single side effect from the preferences adapter put callback
Flo0807 f9f5d67
Collapse preference route patterns to one segmentation rule
Flo0807 6c02842
Route LiveResource preference writes through the adapter
Flo0807 4e1d540
Wrap the sidebar slot in the ul its children require
Flo0807 c9a86ca
Run the Preferences.Context doctests and pin the client trust boundary
Flo0807 e5fd21e
Apply the init_order default instead of dropping it
Flo0807 a809271
Mirror persisted index state into the live session
Flo0807 4d0c1de
Merge preference subtree reads across adapters
Flo0807 2f64af4
Validate persisted resource ordering
Flo0807 72fbbb3
Remove added preference specs
Flo0807 c29a664
Remove unused preferences adapter alias
Flo0807 9112b49
Derive default order from primary key
Flo0807 c69da2b
Simplify preference route matching
Flo0807 f2bb81a
Reuse preference dispatch helpers
Flo0807 30c7a28
Fix preferences feature documentation
Flo0807 eb324a9
Rebuild JavaScript assets
Flo0807 3b79dd1
Merge branch 'develop' into feature/collapsible-sidebar
Flo0807 c01abe2
Refuse oversized session writes instead of overflowing the cookie
Flo0807 f96c349
Refuse preference values a built-in reader cannot consume
Flo0807 0de3810
Persist the order preference only once the user picks one
Flo0807 254a44f
Extract preferences_root so custom layouts can persist preferences
Flo0807 e2a34b9
Snap the restored sidebar state instead of animating it
Flo0807 adb1a85
Release the sidebar transition guard after LiveView patches
Flo0807 9222713
Cover the sidebar transition guard against live_patch
Flo0807 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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import * as Hooks from './hooks' | ||
|
|
||
| export { Hooks } | ||
| export { BackpexPreferences, backpexParams } from './hooks/_preferences' |
Large diffs are not rendered by default.
Oops, something went wrong.
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 |
|---|---|---|
| @@ -0,0 +1,269 @@ | ||
| import { BackpexPreferences } from './_preferences' | ||
|
|
||
| // Sidebar state is persisted through BackpexPreferences with | ||
| // `mirror: 'session'`, which covers all three ways this shell gets rendered: | ||
| // | ||
| // - Fresh connect: the server reads the preference adapter and renders | ||
| // `data-sidebar-open`. | ||
| // - live_redirect: no HTTP request happens and LiveView freezes the session at | ||
| // websocket-connect time, so the server re-renders the shell from a stale | ||
| // snapshot. The sessionStorage mirror, handed back in the connect params, | ||
| // keeps the user's choice authoritative. | ||
| // - Reload right after a toggle: the POST has not landed yet, so the session | ||
| // cookie the document GET carries is one write behind. The short-lived | ||
| // `backpex_prefs` cookie carries the unacknowledged write to the dead render. | ||
| // | ||
| // The server therefore only ever has NEW information for this hook when the | ||
| // write it is rendering has been acknowledged — which is exactly the condition | ||
| // `updated()` adopts on. See assets/js/hooks/_preferences.js and the "Writing a | ||
| // JS hook that persists preferences" section of the user preferences guide. If | ||
| // you add another JS-driven UI-chrome preference, follow the same pattern | ||
| // instead of rolling your own sessionStorage layer. | ||
|
|
||
| /** | ||
| * Manages sidebar open/close drawer state for mobile and desktop. | ||
| * | ||
| * Desktop: sidebar visible by default, content shifts when closed | ||
| * Mobile: sidebar hidden by default, overlays content when opened | ||
| * | ||
| * Section expand/collapse lives in the separate BackpexSidebarSections hook. | ||
| */ | ||
| export default { | ||
| FOCUSABLE_SELECTOR: | ||
| 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', | ||
|
|
||
| mounted () { | ||
| this.sidebar = document.getElementById('backpex-sidebar') | ||
| this.overlay = document.getElementById('backpex-sidebar-overlay') | ||
| this.main = document.getElementById('backpex-main') | ||
| this.toggleBtn = document.getElementById('backpex-sidebar-toggle') | ||
|
|
||
| // No sidebar slot rendered; hook has nothing to do. | ||
| if (!this.sidebar || !this.toggleBtn) return | ||
|
|
||
| // State: mobile closed by default. Desktop state prefers the | ||
| // sessionStorage mirror over the server-rendered data attribute — same | ||
| // live_redirect staleness reason as the section states. By the time hooks | ||
| // mount, connectParams() has primed the mirror, so the two agree unless | ||
| // this tab holds an unacknowledged write — in which case the mirror is | ||
| // right and the attribute may not be. | ||
| this.mobileOpen = false | ||
| this.desktopOpen = BackpexPreferences.get( | ||
| 'global.sidebar_open', | ||
| this.el.dataset.sidebarOpen === 'true' | ||
| ) | ||
| // Last server-rendered value, so updated() can tell a *changed* attribute | ||
| // (new information) from the server merely re-rendering what it had. | ||
| this.serverOpen = this.el.dataset.sidebarOpen === 'true' | ||
| // Element focused before the mobile drawer was opened, for focus restore. | ||
| this.previousFocus = null | ||
|
|
||
| // Track Tailwind's lg breakpoint via its CSS custom property so CSS | ||
| // `lg:` utilities and this hook stay in sync if the user customizes it. | ||
| // Falls back to the Tailwind v4 default when the variable is not defined. | ||
| const breakpoint = | ||
| getComputedStyle(document.documentElement) | ||
| .getPropertyValue('--breakpoint-lg') | ||
| .trim() || '64rem' | ||
| this.mediaQuery = window.matchMedia(`(min-width: ${breakpoint})`) | ||
|
|
||
| // Apply initial state (CSS sets visible by default, JS hides on mobile) | ||
| this.applyState() | ||
|
|
||
| this.releaseTransitions() | ||
|
|
||
| // Event listeners (bound so they can be removed in destroyed()) | ||
| this._onToggleClick = () => this.handleToggle() | ||
| this._onOverlayClick = () => this.closeMobile() | ||
| this._onMediaChange = (e) => this.handleResize(e) | ||
| this._onKeydown = (e) => this.handleKeydown(e) | ||
|
|
||
| this.toggleBtn.addEventListener('click', this._onToggleClick) | ||
| this.overlay.addEventListener('click', this._onOverlayClick) | ||
| this.mediaQuery.addEventListener('change', this._onMediaChange) | ||
|
|
||
| document.addEventListener('keydown', this._onKeydown) | ||
| }, | ||
|
|
||
| updated () { | ||
| if (!this.sidebar || !this.toggleBtn) return | ||
|
|
||
| // Edge-triggered: `desktopOpen` drives higher-specificity `data-[state]` | ||
| // classes, so re-asserting a stale cached value on every render would | ||
| // permanently override the server. Adopt the attribute only when it | ||
| // *changed* — and only when this tab has no write the server has yet to | ||
| // acknowledge, since such a render was necessarily produced without it. | ||
| const serverOpen = this.el.dataset.sidebarOpen === 'true' | ||
| if (serverOpen !== this.serverOpen) { | ||
| this.serverOpen = serverOpen | ||
| if (!BackpexPreferences.isPending('global.sidebar_open')) this.desktopOpen = serverOpen | ||
| } | ||
|
|
||
| this.applyState() | ||
|
|
||
| // `data-suppress-transition` is static markup, so any patch that re-renders | ||
| // the shell — a live_patch from sorting or filtering, say — morphs it back | ||
| // onto these elements. Nothing else takes it off again, which left the | ||
| // sidebar unable to animate for the rest of the page load. | ||
| this.releaseTransitions() | ||
| }, | ||
|
|
||
| destroyed () { | ||
| this.toggleBtn?.removeEventListener('click', this._onToggleClick) | ||
| this.overlay?.removeEventListener('click', this._onOverlayClick) | ||
| this.mediaQuery?.removeEventListener('change', this._onMediaChange) | ||
| document.removeEventListener('keydown', this._onKeydown) | ||
|
|
||
| // Drop inert in case the hook is torn down while the mobile drawer is open | ||
| // so the main content doesn't stay unreachable across live_redirects. | ||
| this.main?.removeAttribute('inert') | ||
| }, | ||
|
|
||
| // `data-suppress-transition` is server-rendered so the correction applyState() | ||
| // may have just made — the mirror disagreeing with a dead render one write | ||
| // behind — snaps instead of animating. Releasing it in the same style-change | ||
| // event that applies that correction defeats it: a transition starts from the | ||
| // *after-change* style, so the suppressed before-change style buys nothing and | ||
| // the sidebar slides for 300ms instead. Force the corrected state through a | ||
| // style recalculation while still suppressed, so it becomes the before-change | ||
| // style, then release against an unchanged value. | ||
| // | ||
| // Deliberately synchronous rather than requestAnimationFrame: rAF never fires | ||
| // in a background tab, which would strand the guard and leave the sidebar | ||
| // unable to animate for the rest of the page load. | ||
| releaseTransitions () { | ||
| if ( | ||
| !this.sidebar.hasAttribute('data-suppress-transition') && | ||
| !this.main.hasAttribute('data-suppress-transition') | ||
| ) return | ||
|
|
||
| // Reading layout flushes the pending style change. | ||
| this.sidebar.getBoundingClientRect() | ||
| this.main.getBoundingClientRect() | ||
|
|
||
| this.sidebar.removeAttribute('data-suppress-transition') | ||
| this.main.removeAttribute('data-suppress-transition') | ||
| }, | ||
|
|
||
| isDesktop () { | ||
| return this.mediaQuery.matches | ||
| }, | ||
|
|
||
| handleToggle () { | ||
| if (this.isDesktop()) { | ||
| this.desktopOpen = !this.desktopOpen | ||
| // Writes the sessionStorage mirror and marks the key pending in the | ||
| // `backpex_prefs` cookie, both synchronously, before POSTing. Until that | ||
| // POST responds this value beats anything the server renders. | ||
| BackpexPreferences.set('global.sidebar_open', this.desktopOpen, { mirror: 'session' }) | ||
| } else { | ||
| if (!this.mobileOpen) this.previousFocus = document.activeElement | ||
| this.mobileOpen = !this.mobileOpen | ||
| } | ||
| this.applyState() | ||
| if (!this.isDesktop() && this.mobileOpen) this.focusFirstInSidebar() | ||
| }, | ||
|
|
||
| closeMobile () { | ||
| const wasOpen = this.mobileOpen | ||
| this.mobileOpen = false | ||
| this.applyState() | ||
| if (wasOpen) this.restorePreviousFocus() | ||
| }, | ||
|
|
||
| handleResize (event) { | ||
| if (event.matches) { | ||
| this.mobileOpen = false | ||
| this.previousFocus = null | ||
| } | ||
| this.applyState() | ||
| }, | ||
|
|
||
| handleKeydown (event) { | ||
| if (!this.mobileOpen || this.isDesktop()) return | ||
|
|
||
| if (event.key === 'Escape') { | ||
| this.closeMobile() | ||
| return | ||
| } | ||
|
|
||
| if (event.key === 'Tab') this.trapTab(event) | ||
| }, | ||
|
|
||
| trapTab (event) { | ||
| const focusable = this.visibleFocusable() | ||
| if (focusable.length === 0) { | ||
| event.preventDefault() | ||
| return | ||
| } | ||
|
|
||
| const first = focusable[0] | ||
| const last = focusable[focusable.length - 1] | ||
| const active = document.activeElement | ||
|
|
||
| if (event.shiftKey && (active === first || !this.sidebar.contains(active))) { | ||
| event.preventDefault() | ||
| last.focus() | ||
| } else if (!event.shiftKey && active === last) { | ||
| event.preventDefault() | ||
| first.focus() | ||
| } | ||
| }, | ||
|
|
||
| // Focusable descendants that are actually rendered. Links inside a | ||
| // collapsed section are display:none (offsetParent === null); leaving | ||
| // them in would anchor the trap's first/last on an unfocusable element | ||
| // and let Tab escape the modal drawer. No focusable inside the sidebar | ||
| // is position:fixed, so a null offsetParent reliably means hidden here. | ||
| visibleFocusable () { | ||
| return Array.from(this.sidebar.querySelectorAll(this.FOCUSABLE_SELECTOR)) | ||
| .filter((el) => el.offsetParent !== null) | ||
| }, | ||
|
|
||
| focusFirstInSidebar () { | ||
| const focusable = this.visibleFocusable()[0] | ||
| if (focusable) focusable.focus() | ||
| }, | ||
|
|
||
| restorePreviousFocus () { | ||
| if (this.previousFocus && document.contains(this.previousFocus)) { | ||
| this.previousFocus.focus() | ||
| } | ||
| this.previousFocus = null | ||
| }, | ||
|
|
||
| applyState () { | ||
| const isDesktop = this.isDesktop() | ||
| const sidebarVisible = isDesktop ? this.desktopOpen : this.mobileOpen | ||
|
|
||
| // Declarative state: write data attributes and let CSS map them to the | ||
| // translate / margin / overlay styles (see the app_shell classes). One | ||
| // source of truth, and no inline styles fighting the SSR `lg:` defaults. | ||
| this.sidebar.dataset.state = sidebarVisible ? 'open' : 'closed' | ||
|
|
||
| // Remove off-canvas sidebar from tab order and accessibility tree | ||
| this.sidebar.toggleAttribute('inert', !sidebarVisible) | ||
|
|
||
| // Main content shifts only when the desktop sidebar is open. | ||
| this.main.dataset.shift = isDesktop && this.desktopOpen ? 'on' : 'off' | ||
|
|
||
| // Overlay is shown only for the open mobile drawer. | ||
| this.overlay.dataset.visible = !isDesktop && this.mobileOpen ? 'on' : 'off' | ||
|
|
||
| // ARIA | ||
| this.toggleBtn.setAttribute('aria-expanded', sidebarVisible.toString()) | ||
|
|
||
| // Mobile drawer behaves as a modal dialog; desktop is inline chrome. | ||
| if (!isDesktop && this.mobileOpen) { | ||
| this.sidebar.setAttribute('role', 'dialog') | ||
| this.sidebar.setAttribute('aria-modal', 'true') | ||
| } else { | ||
| this.sidebar.removeAttribute('role') | ||
| this.sidebar.removeAttribute('aria-modal') | ||
| } | ||
|
|
||
| // aria-modal needs a matching inert region; the topbar and main content | ||
| // live inside #backpex-main, so inerting that element covers both. | ||
| this.main.toggleAttribute('inert', !isDesktop && this.mobileOpen) | ||
| } | ||
| } | ||
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.
Uh oh!
There was an error while loading. Please reload this page.