Pass prop/add reused logic service

This commit is contained in:
tidy-dev 2022-11-29 08:45:06 -05:00
parent d7bb19f00c
commit 213df72ee6
7 changed files with 141 additions and 141 deletions

View file

@ -16,7 +16,7 @@ import { RelativeTime } from '../relative-time'
import { assertNever } from '../../lib/fatal-error'
import { ReleaseNotesUri } from '../lib/releases'
import { encodePathAsUrl } from '../../lib/path'
import { DialogStackContextConsumer } from '../dialog/dialog-stack-context-consumer'
import { IsTopMostService } from '../dialog/is-top-most-service'
const logoPath = __DARWIN__
? 'static/logo-64x64@2x.png'
@ -55,6 +55,9 @@ interface IAboutProps {
/** A function to call when the user wants to see Terms and Conditions. */
readonly onShowTermsAndConditions: () => void
/** Whether the dialog is the top most in the dialog stack */
readonly isTopMost: boolean
}
interface IAboutState {
@ -66,11 +69,18 @@ interface IAboutState {
* A dialog that presents information about the
* running application such as name and version.
*/
export class About extends DialogStackContextConsumer<
IAboutProps,
IAboutState
> {
export class About extends React.Component<IAboutProps, IAboutState> {
private updateStoreEventHandle: Disposable | null = null
private isTopMostService: IsTopMostService = new IsTopMostService(
() => {
window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('keyup', this.onKeyUp)
},
() => {
window.removeEventListener('keydown', this.onKeyDown)
window.removeEventListener('keyup', this.onKeyUp)
}
)
public constructor(props: IAboutProps) {
super(props)
@ -90,7 +100,11 @@ export class About extends DialogStackContextConsumer<
this.onUpdateStateChanged
)
this.setState({ updateState: updateStore.state })
super.componentDidMount()
this.isTopMostService.check(this.props.isTopMost)
}
public componentDidUpdate(): void {
this.isTopMostService.check(this.props.isTopMost)
}
public componentWillUnmount() {
@ -98,17 +112,7 @@ export class About extends DialogStackContextConsumer<
this.updateStoreEventHandle.dispose()
this.updateStoreEventHandle = null
}
super.componentWillUnmount()
}
protected onDialogIsTopMost() {
window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('keyup', this.onKeyUp)
}
protected onDialogIsNotTopMost() {
window.removeEventListener('keydown', this.onKeyDown)
window.removeEventListener('keyup', this.onKeyUp)
this.isTopMostService.unmount()
}
private onKeyDown = (event: KeyboardEvent) => {

View file

@ -36,7 +36,7 @@ import { mkdir } from 'fs/promises'
import { directoryExists } from '../../lib/directory-exists'
import { FoldoutType } from '../../lib/app-state'
import { join } from 'path'
import { DialogStackContextConsumer } from '../dialog/dialog-stack-context-consumer'
import { IsTopMostService } from '../dialog/is-top-most-service'
/** The sentinel value used to indicate no gitignore should be used. */
const NoGitIgnoreValue = 'None'
@ -72,6 +72,9 @@ interface ICreateRepositoryProps {
/** Prefills path input so user doesn't have to. */
readonly initialPath?: string
/** Whether the dialog is the top most in the dialog stack */
readonly isTopMost: boolean
}
interface ICreateRepositoryState {
@ -112,10 +115,20 @@ interface ICreateRepositoryState {
}
/** The Create New Repository component. */
export class CreateRepository extends DialogStackContextConsumer<
export class CreateRepository extends React.Component<
ICreateRepositoryProps,
ICreateRepositoryState
> {
private isTopMostService: IsTopMostService = new IsTopMostService(
() => {
this.updateReadMeExists(this.state.path, this.state.name)
window.addEventListener('focus', this.onWindowFocus)
},
() => {
window.removeEventListener('focus', this.onWindowFocus)
}
)
public constructor(props: ICreateRepositoryProps) {
super(props)
@ -146,7 +159,7 @@ export class CreateRepository extends DialogStackContextConsumer<
}
public async componentDidMount() {
super.componentDidMount()
this.isTopMostService.check(this.props.isTopMost)
const gitIgnoreNames = await getGitIgnoreNames()
const licenses = await getLicenses()
@ -159,12 +172,12 @@ export class CreateRepository extends DialogStackContextConsumer<
this.updateReadMeExists(path, this.state.name)
}
protected onDialogIsTopMost() {
window.addEventListener('focus', this.onWindowFocus)
public componentDidUpdate(): void {
this.isTopMostService.check(this.props.isTopMost)
}
protected onDialogIsNotTopMost() {
window.removeEventListener('focus', this.onWindowFocus)
public componentWillUnmount(): void {
this.isTopMostService.unmount()
}
private initializePath = async () => {

View file

@ -165,11 +165,8 @@ import { sendNonFatalException } from '../lib/helpers/non-fatal-exception'
import { createCommitURL } from '../lib/commit-url'
import { uuid } from '../lib/uuid'
import { InstallingUpdate } from './installing-update/installing-update'
import {
DialogStackContext,
IDialogStackContext,
} from './dialog/dialog-stack-context-consumer'
import { enableStackedPopups } from '../lib/feature-flag'
import { DialogStackContext } from './dialog'
const MinuteInMilliseconds = 1000 * 60
const HourInMilliseconds = MinuteInMilliseconds * 60
@ -1416,15 +1413,15 @@ export class App extends React.Component<IAppProps, IAppState> {
return (
<>
{allPopups.map(popup => {
const dialogStackContext: IDialogStackContext = {
isTopMost: this.state.currentPopup?.id === popup.id,
}
const isTopMost = this.state.currentPopup?.id === popup.id
return (
<DialogStackContext.Provider
key={popup.id}
value={dialogStackContext}
value={{
isTopMost,
}}
>
{this.popupContent(popup)}
{this.popupContent(popup, isTopMost)}
</DialogStackContext.Provider>
)
})}
@ -1432,7 +1429,7 @@ export class App extends React.Component<IAppProps, IAppState> {
)
}
private popupContent(popup: Popup): JSX.Element | null {
private popupContent(popup: Popup, isTopMost: boolean): JSX.Element | null {
if (popup.id === undefined) {
// Should not be possible... but if it does we want to know about it.
sendNonFatalException(
@ -1607,6 +1604,7 @@ export class App extends React.Component<IAppProps, IAppState> {
onDismissed={onPopupDismissedFn}
dispatcher={this.props.dispatcher}
initialPath={popup.path}
isTopMost={isTopMost}
/>
)
case PopupType.CloneRepository:
@ -1622,6 +1620,7 @@ export class App extends React.Component<IAppProps, IAppState> {
onTabSelected={this.onCloneRepositoriesTabSelected}
apiRepositories={this.state.apiRepositories}
onRefreshRepositories={this.onRefreshRepositories}
isTopMost={isTopMost}
/>
)
case PopupType.CreateBranch: {
@ -1682,6 +1681,7 @@ export class App extends React.Component<IAppProps, IAppState> {
onCheckForNonStaggeredUpdates={this.onCheckForNonStaggeredUpdates}
onShowAcknowledgements={this.showAcknowledgements}
onShowTermsAndConditions={this.showTermsAndConditions}
isTopMost={isTopMost}
/>
)
case PopupType.PublishRepository:

View file

@ -24,7 +24,7 @@ import { ClickSource } from '../lib/list'
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
import { showOpenDialog, showSaveDialog } from '../main-process-proxy'
import { readdir } from 'fs/promises'
import { DialogStackContextConsumer } from '../dialog/dialog-stack-context-consumer'
import { IsTopMostService } from '../dialog/is-top-most-service'
interface ICloneRepositoryProps {
readonly dispatcher: Dispatcher
@ -66,6 +66,9 @@ interface ICloneRepositoryProps {
* available for cloning.
*/
readonly onRefreshRepositories: (account: Account) => void
/** Whether the dialog is the top most in the dialog stack */
readonly isTopMost: boolean
}
interface ICloneRepositoryState {
@ -145,10 +148,20 @@ interface IGitHubTabState extends IBaseTabState {
}
/** The component for cloning a repository. */
export class CloneRepository extends DialogStackContextConsumer<
export class CloneRepository extends React.Component<
ICloneRepositoryProps,
ICloneRepositoryState
> {
private isTopMostService: IsTopMostService = new IsTopMostService(
() => {
this.validatePath()
window.addEventListener('focus', this.onWindowFocus)
},
() => {
window.removeEventListener('focus', this.onWindowFocus)
}
)
public constructor(props: ICloneRepositoryProps) {
super(props)
@ -194,7 +207,7 @@ export class CloneRepository extends DialogStackContextConsumer<
this.updateUrl(this.props.initialURL || '')
}
super.componentDidUpdate(prevProps)
this.isTopMostService.check(this.props.isTopMost)
}
public componentDidMount() {
@ -203,7 +216,11 @@ export class CloneRepository extends DialogStackContextConsumer<
this.updateUrl(initialURL)
}
super.componentDidMount()
this.isTopMostService.check(this.props.isTopMost)
}
public componentWillUnmount(): void {
this.isTopMostService.unmount()
}
private initializePath = async () => {
@ -227,14 +244,6 @@ export class CloneRepository extends DialogStackContextConsumer<
this.updateUrl(selectedTabState.url)
}
protected onDialogIsTopMost() {
window.addEventListener('focus', this.onWindowFocus)
}
protected onDialogIsNotTopMost() {
window.removeEventListener('focus', this.onWindowFocus)
}
public render() {
const { error } = this.getSelectedTabState()
return (

View file

@ -1,70 +0,0 @@
import memoizeOne from 'memoize-one'
import * as React from 'react'
export interface IDialogStackContext {
/** Whether or not this dialog is the top most one in the stack to be
* interacted with by the user. This will also determine if event listeners
* will be active or not. */
isTopMost: boolean
}
/**
* The DialogStackContext is used to communicate between the `Dialog` and the
* `App` information that is mostly unique to the `Dialog` component such as
* whether it is at the top of the popup stack. Some, but not the vast majority,
* custom popup components in between may also utilize this to enable and
* disable event listeners in response to changes in whether it is the top most
* popup.
*
* NB *** React.Context is not the preferred method of passing data to child
* components for this code base. We are choosing to use it here as implementing
* prop drilling would be extremely tedious and would lead to adding `Dialog`
* props on 60+ components that would not otherwise use them. ***
*
*/
export const DialogStackContext = React.createContext<IDialogStackContext>({
isTopMost: false,
})
/**
* A base component for any dialogs that consume the dialog stack context.
*
* This houses logic to respond to when the `isTopMost` changes on the
* `DialogStackContext` by providing two abstract methods of `onDialogIsTopMost`
* and `onDialogIsNotTopMost` and implementing a `checkWhetherDialogIsTopMost
* method that called via the components implementations of React component
* lifecycle methods.
*/
export abstract class DialogStackContextConsumer<K, T> extends React.Component<
K,
T
> {
public static contextType = DialogStackContext
public declare context: React.ContextType<typeof DialogStackContext>
protected checkWhetherDialogIsTopMost = memoizeOne((isTopMost: boolean) => {
if (isTopMost) {
this.onDialogIsTopMost()
} else {
this.onDialogIsNotTopMost()
}
})
/** The method called when the dialog is the top most in the stack. */
protected abstract onDialogIsTopMost(): void
/** The method called when the dialog is not top most in the stack. */
protected abstract onDialogIsNotTopMost(): void
public componentDidUpdate(prevProps: K): void {
this.checkWhetherDialogIsTopMost(this.context.isTopMost)
}
public componentDidMount() {
this.checkWhetherDialogIsTopMost(this.context.isTopMost)
}
public componentWillUnmount() {
this.onDialogIsNotTopMost()
}
}

View file

@ -3,8 +3,32 @@ import classNames from 'classnames'
import { DialogHeader } from './header'
import { createUniqueId, releaseUniqueId } from '../lib/id-pool'
import { getTitleBarHeight } from '../window/title-bar'
import memoizeOne from 'memoize-one'
import { DialogStackContextConsumer } from './dialog-stack-context-consumer'
import { IsTopMostService } from './is-top-most-service'
export interface IDialogStackContext {
/** Whether or not this dialog is the top most one in the stack to be
* interacted with by the user. This will also determine if event listeners
* will be active or not. */
isTopMost: boolean
}
/**
* The DialogStackContext is used to communicate between the `Dialog` and the
* `App` information that is mostly unique to the `Dialog` component such as
* whether it is at the top of the popup stack. Some, but not the vast majority,
* custom popup components in between may also utilize this to enable and
* disable event listeners in response to changes in whether it is the top most
* popup.
*
* NB *** React.Context is not the preferred method of passing data to child
* components for this code base. We are choosing to use it here as implementing
* prop drilling would be extremely tedious and would lead to adding `Dialog`
* props on 60+ components that would not otherwise use them. ***
*
*/
export const DialogStackContext = React.createContext<IDialogStackContext>({
isTopMost: false,
})
/**
* The time (in milliseconds) from when the dialog is mounted
@ -139,30 +163,25 @@ interface IDialogState {
* underlying elements. It's not possible to use the tab key to move focus
* out of the dialog without first dismissing it.
*/
export class Dialog extends DialogStackContextConsumer<
IDialogProps,
IDialogState
> {
export class Dialog extends React.Component<IDialogProps, IDialogState> {
public static contextType = DialogStackContext
public declare context: React.ContextType<typeof DialogStackContext>
private isTopMostService: IsTopMostService = new IsTopMostService(
() => {
this.onDialogIsTopMost()
},
() => {
this.onDialogIsNotTopMost()
}
)
private dialogElement: HTMLDialogElement | null = null
private dismissGraceTimeoutId?: number
private disableClickDismissalTimeoutId: number | null = null
private disableClickDismissal = false
protected checkWhetherDialogIsTopMost = memoizeOne((isTopMost: boolean) => {
if (this.dialogElement == null) {
return
}
if (isTopMost && !this.dialogElement.open) {
this.onDialogIsTopMost()
}
if (!isTopMost && this.dialogElement.open) {
this.onDialogIsNotTopMost()
}
})
/**
* Resize observer used for tracking width changes and
* refreshing the internal codemirror instance when
@ -267,12 +286,18 @@ export class Dialog extends DialogStackContextConsumer<
this.updateTitleId()
}
public componentDidMount() {
this.isTopMostService.check(this.context.isTopMost)
}
protected onDialogIsTopMost() {
if (this.dialogElement == null) {
return
}
this.dialogElement.showModal()
if (!this.dialogElement.open) {
this.dialogElement.showModal()
}
// Provide an event that components can subscribe to in order to perform
// tasks such as re-layout after the dialog is visible
@ -295,9 +320,8 @@ export class Dialog extends DialogStackContextConsumer<
}
protected onDialogIsNotTopMost() {
if (this.dialogElement !== null) {
if (this.dialogElement !== null && this.dialogElement.open) {
this.dialogElement?.close()
return
}
this.clearDismissGraceTimeout()
@ -463,7 +487,7 @@ export class Dialog extends DialogStackContextConsumer<
releaseUniqueId(this.state.titleId)
}
super.componentWillUnmount()
this.isTopMostService.unmount()
}
public componentDidUpdate(prevProps: IDialogProps) {
@ -471,7 +495,7 @@ export class Dialog extends DialogStackContextConsumer<
this.updateTitleId()
}
super.componentDidUpdate(prevProps)
this.isTopMostService.check(this.context.isTopMost)
}
private onDialogCancel = (e: Event | React.SyntheticEvent) => {

View file

@ -0,0 +1,20 @@
import memoizeOne from 'memoize-one'
export class IsTopMostService {
public check = memoizeOne((isTopMost: boolean) => {
if (isTopMost) {
this.onDialogIsTopMost()
} else {
this.onDialogIsNotTopMost()
}
})
public constructor(
private onDialogIsTopMost: () => void,
private onDialogIsNotTopMost: () => void
) {}
public unmount() {
this.onDialogIsNotTopMost()
}
}