Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
coopwire-hypothesis
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
孙灵跃 Leon Sun
coopwire-hypothesis
Commits
2fa444e0
Commit
2fa444e0
authored
Aug 02, 2023
by
Alejandro Celaya
Committed by
Alejandro Celaya
Sep 25, 2023
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Use new ToastMessages component with internal dismissing handling
parent
240616a8
Changes
13
Hide whitespace changes
Inline
Side-by-side
Showing
13 changed files
with
407 additions
and
325 deletions
+407
-325
ToastMessages.tsx
src/annotator/components/ToastMessages.tsx
+2
-2
ToastMessages-test.js
src/annotator/components/test/ToastMessages-test.js
+6
-3
sidebar.tsx
src/annotator/sidebar.tsx
+1
-1
ToastMessages.tsx
src/shared/components/ToastMessages.tsx
+205
-0
BaseToastMessages-test.js
src/shared/components/test/BaseToastMessages-test.js
+0
-153
ToastMessages-test.js
src/shared/components/test/ToastMessages-test.js
+159
-0
ToastMessages.tsx
src/sidebar/components/ToastMessages.tsx
+19
-5
ToastMessages-test.js
src/sidebar/components/test/ToastMessages-test.js
+2
-2
frame-sync.ts
src/sidebar/services/frame-sync.ts
+1
-1
toast-messenger-test.js
src/sidebar/services/test/toast-messenger-test.js
+3
-86
toast-messenger.ts
src/sidebar/services/toast-messenger.ts
+8
-32
toast-messages-test.js
src/sidebar/store/modules/test/toast-messages-test.js
+0
-21
toast-messages.ts
src/sidebar/store/modules/toast-messages.ts
+1
-19
No files found.
src/annotator/components/ToastMessages.tsx
View file @
2fa444e0
import
{
useCallback
,
useEffect
,
useState
}
from
'preact/hooks'
;
import
BaseToastMessages
from
'../../shared/components/
Base
ToastMessages'
;
import
type
{
ToastMessage
}
from
'../../shared/components/
Base
ToastMessages'
;
import
BaseToastMessages
from
'../../shared/components/ToastMessages'
;
import
type
{
ToastMessage
}
from
'../../shared/components/ToastMessages'
;
import
type
{
Emitter
}
from
'../util/emitter'
;
export
type
ToastMessagesProps
=
{
...
...
src/annotator/components/test/ToastMessages-test.js
View file @
2fa444e0
...
...
@@ -17,6 +17,9 @@ describe('ToastMessages', () => {
const
createComponent
=
()
=>
mount
(
<
ToastMessages
emitter
=
{
emitter
}
/>
)
;
const
toastMessagesList
=
wrapper
=>
wrapper
.
find
(
'[messages]'
).
prop
(
'messages'
);
beforeEach
(()
=>
{
emitter
=
new
Emitter
(
new
EventEmitter
());
});
...
...
@@ -25,14 +28,14 @@ describe('ToastMessages', () => {
const
wrapper
=
createComponent
();
// Initially messages is empty
assert
.
lengthOf
(
wrapper
.
find
(
'BaseToastMessages'
).
prop
(
'messages'
),
0
);
assert
.
lengthOf
(
toastMessagesList
(
wrapper
),
0
);
emitter
.
publish
(
'toastMessageAdded'
,
fakeMessage
(
'someId1'
));
emitter
.
publish
(
'toastMessageAdded'
,
fakeMessage
(
'someId2'
));
emitter
.
publish
(
'toastMessageAdded'
,
fakeMessage
(
'someId3'
));
wrapper
.
update
();
assert
.
lengthOf
(
wrapper
.
find
(
'BaseToastMessages'
).
prop
(
'messages'
),
3
);
assert
.
lengthOf
(
toastMessagesList
(
wrapper
),
3
);
});
it
(
'removes toast existing messages on toastMessageDismissed'
,
()
=>
{
...
...
@@ -49,6 +52,6 @@ describe('ToastMessages', () => {
emitter
.
publish
(
'toastMessageDismissed'
,
'someId4'
);
wrapper
.
update
();
assert
.
lengthOf
(
wrapper
.
find
(
'BaseToastMessages'
).
prop
(
'messages'
),
2
);
assert
.
lengthOf
(
toastMessagesList
(
wrapper
),
2
);
});
});
src/annotator/sidebar.tsx
View file @
2fa444e0
...
...
@@ -2,7 +2,7 @@ import classnames from 'classnames';
import
*
as
Hammer
from
'hammerjs'
;
import
{
render
}
from
'preact'
;
import
type
{
ToastMessage
}
from
'../shared/components/
Base
ToastMessages'
;
import
type
{
ToastMessage
}
from
'../shared/components/ToastMessages'
;
import
{
addConfigFragment
}
from
'../shared/config-fragment'
;
import
{
sendErrorsTo
}
from
'../shared/frame-error-capture'
;
import
{
ListenerCollection
}
from
'../shared/listener-collection'
;
...
...
src/shared/components/
Base
ToastMessages.tsx
→
src/shared/components/ToastMessages.tsx
View file @
2fa444e0
import
{
Callout
,
Link
}
from
'@hypothesis/frontend-shared'
;
import
type
{
TransitionComponent
}
from
'@hypothesis/frontend-shared'
;
import
{
Callout
}
from
'@hypothesis/frontend-shared'
;
import
classnames
from
'classnames'
;
import
type
{
ComponentChildren
,
ComponentProps
,
FunctionComponent
,
}
from
'preact'
;
import
{
useCallback
,
useMemo
,
useRef
,
useState
}
from
'preact/hooks'
;
export
type
ToastMessage
=
{
type
:
'error'
|
'success'
|
'notice'
;
id
:
string
;
message
:
string
;
moreInfoURL
:
string
;
isDismissed
:
boolean
;
visuallyHidden
:
boolean
;
type
:
'error'
|
'success'
|
'notice'
;
message
:
ComponentChildren
;
/**
* Visually hidden messages are announced to screen readers but not visible.
* Defaults to false.
*/
visuallyHidden
?:
boolean
;
/**
* Determines if the toast message should be auto-dismissed.
* Defaults to true.
*/
autoDismiss
?:
boolean
;
};
export
type
ToastMessageTransitionClasses
=
{
/** Classes to apply to a toast message when appended. Defaults to 'animate-fade-in' */
transitionIn
?:
string
;
/** Classes to apply to a toast message being dismissed. Defaults to 'animate-fade-out' */
transitionOut
?:
string
;
};
type
ToastMessageItemProps
=
{
...
...
@@ -28,13 +51,6 @@ function ToastMessageItem({ message, onDismiss }: ToastMessageItemProps) {
?
`
${
message
.
type
.
charAt
(
0
).
toUpperCase
()
+
message
.
type
.
slice
(
1
)}
: `
:
''
;
/**
* There is a click-to-remove handler on a non-interactive element. This
* allows sighted users to get the toast message out of their way if it
* interferes with interacting with the underlying components. This shouldn't
* pose the same irritation to users with screen- readers as the rendered
* toast messages shouldn't impede interacting with the underlying document.
*/
return
(
<
Callout
classes=
{
classnames
({
...
...
@@ -46,56 +62,124 @@ function ToastMessageItem({ message, onDismiss }: ToastMessageItemProps) {
>
<
strong
>
{
prefix
}
</
strong
>
{
message
.
message
}
{
message
.
moreInfoURL
&&
(
<
div
className=
"text-right"
>
<
Link
href=
{
message
.
moreInfoURL
}
onClick=
{
event
=>
event
.
stopPropagation
()
/* consume the event so that it does not dismiss the message */
}
target=
"_new"
underline=
"none"
>
More info
</
Link
>
</
div
>
)
}
</
Callout
>
);
}
export
type
BaseToastMessageProps
=
{
type
BaseToastMessageTransitionType
=
FunctionComponent
<
ComponentProps
<
TransitionComponent
>
&
{
transitionClasses
?:
ToastMessageTransitionClasses
;
}
>
;
const
BaseToastMessageTransition
:
BaseToastMessageTransitionType
=
({
direction
,
onTransitionEnd
,
children
,
transitionClasses
=
{},
})
=>
{
const
isDismissed
=
direction
===
'out'
;
const
containerRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
handleAnimation
=
(
e
:
AnimationEvent
)
=>
{
// Ignore animations happening on child elements
if
(
e
.
target
!==
containerRef
.
current
)
{
return
;
}
onTransitionEnd
?.(
direction
??
'in'
);
};
const
classes
=
useMemo
(()
=>
{
const
{
transitionIn
=
'animate-fade-in'
,
transitionOut
=
'animate-fade-out'
,
}
=
transitionClasses
;
return
{
[
transitionIn
]:
!
isDismissed
,
[
transitionOut
]:
isDismissed
,
};
},
[
isDismissed
,
transitionClasses
]);
return
(
<
div
data
-
testid=
"animation-container"
onAnimationEnd=
{
handleAnimation
}
ref=
{
containerRef
}
className=
{
classnames
(
'relative w-full container'
,
classes
)
}
>
{
children
}
</
div
>
);
};
export
type
ToastMessagesProps
=
{
messages
:
ToastMessage
[];
onMessageDismiss
:
(
messageId
:
string
)
=>
void
;
onMessageDismiss
:
(
id
:
string
)
=>
void
;
transitionClasses
?:
ToastMessageTransitionClasses
;
setTimeout_
?:
typeof
setTimeout
;
};
/**
* A collection of toast messages. These are rendered within an `aria-live`
* region for accessibility with screen readers.
*/
export
default
function
Base
ToastMessages
({
export
default
function
ToastMessages
({
messages
,
onMessageDismiss
,
}:
BaseToastMessageProps
)
{
// The `ul` containing any toast messages is absolute-positioned and the full
// width of the viewport. Each toast message `li` has its position and width
// constrained by `container` configuration in tailwind.
transitionClasses
,
/* istanbul ignore next - test seam */
setTimeout_
=
setTimeout
,
}:
ToastMessagesProps
)
{
const
[
dismissedMessages
,
setDismissedMessages
]
=
useState
<
string
[]
>
([]);
const
scheduledMessages
=
useRef
(
new
Set
<
string
>
());
const
dismissMessage
=
useCallback
(
(
id
:
string
)
=>
setDismissedMessages
(
ids
=>
[...
ids
,
id
]),
[],
);
const
scheduleMessageDismiss
=
useCallback
(
(
id
:
string
)
=>
{
if
(
scheduledMessages
.
current
.
has
(
id
))
{
return
;
}
// Track that this message has been scheduled to be dismissed. After a
// period of time, actually dismiss it
scheduledMessages
.
current
.
add
(
id
);
setTimeout_
(()
=>
{
dismissMessage
(
id
);
scheduledMessages
.
current
.
delete
(
id
);
},
5000
);
},
[
dismissMessage
,
setTimeout_
],
);
const
onTransitionEnd
=
useCallback
(
(
direction
:
'in'
|
'out'
,
message
:
ToastMessage
)
=>
{
const
autoDismiss
=
message
.
autoDismiss
??
true
;
if
(
direction
===
'in'
&&
autoDismiss
)
{
scheduleMessageDismiss
(
message
.
id
);
}
if
(
direction
===
'out'
)
{
onMessageDismiss
(
message
.
id
);
setDismissedMessages
(
ids
=>
ids
.
filter
(
id
=>
id
!==
message
.
id
));
}
},
[
scheduleMessageDismiss
,
onMessageDismiss
],
);
return
(
<
div
>
<
ul
aria
-
live=
"polite"
aria
-
relevant=
"additions"
className=
{
classnames
(
// Set an aggressive z-index as we want to ensure toast messages are
// rendered above other content
'z-10'
,
'absolute left-0 w-full'
,
)
}
>
{
messages
.
map
(
message
=>
(
<
ul
aria
-
live=
"polite"
aria
-
relevant=
"additions"
className=
"w-full space-y-2"
>
{
messages
.
map
(
message
=>
{
const
isDismissed
=
dismissedMessages
.
includes
(
message
.
id
);
return
(
<
li
className=
{
classnames
(
'relative w-full container'
,
{
className=
{
classnames
({
// Add a bottom margin to visible messages only. Typically, we'd
// use a `space-y-2` class on the parent to space children.
// Doing that here could cause an undesired top margin on
...
...
@@ -103,20 +187,19 @@ export default function BaseToastMessages({
// visually-hidden messages before it.
// See https://tailwindcss.com/docs/space#limitations
'mb-2'
:
!
message
.
visuallyHidden
,
// Slide in from right in narrow viewports; fade in larger
// viewports to toast message isn't flying too far
'motion-safe:animate-slide-in-from-right lg:animate-fade-in'
:
!
message
.
isDismissed
,
// Only ever fade in if motion-reduction is preferred
'motion-reduce:animate-fade-in'
:
!
message
.
isDismissed
,
'animate-fade-out'
:
message
.
isDismissed
,
})
}
key=
{
message
.
id
}
>
<
ToastMessageItem
message=
{
message
}
onDismiss=
{
onMessageDismiss
}
/>
<
BaseToastMessageTransition
direction=
{
isDismissed
?
'out'
:
'in'
}
onTransitionEnd=
{
direction
=>
onTransitionEnd
(
direction
,
message
)
}
transitionClasses=
{
transitionClasses
}
>
<
ToastMessageItem
message=
{
message
}
onDismiss=
{
dismissMessage
}
/>
</
BaseToastMessageTransition
>
</
li
>
)
)
}
</
ul
>
</
div
>
)
;
})
}
</
ul
>
);
}
src/shared/components/test/BaseToastMessages-test.js
deleted
100644 → 0
View file @
240616a8
import
{
mount
}
from
'enzyme'
;
import
{
act
}
from
'preact/test-utils'
;
import
{
checkAccessibility
}
from
'../../../test-util/accessibility'
;
import
BaseToastMessages
from
'../BaseToastMessages'
;
describe
(
'BaseToastMessages'
,
()
=>
{
let
fakeOnMessageDismiss
;
let
fakeErrorMessage
=
()
=>
{
return
{
type
:
'error'
,
message
:
'boo'
,
id
:
'someid2'
,
isDismissed
:
false
,
};
};
let
fakeSuccessMessage
=
()
=>
{
return
{
type
:
'success'
,
message
:
'yay'
,
id
:
'someid'
,
isDismissed
:
false
,
};
};
let
fakeNoticeMessage
=
()
=>
{
return
{
type
:
'notice'
,
message
:
'you should know...'
,
id
:
'someid3'
,
isDismissed
:
false
,
moreInfoURL
:
'http://www.example.com'
,
};
};
function
createComponent
(
messages
=
[])
{
return
mount
(
<
BaseToastMessages
messages
=
{
messages
}
onMessageDismiss
=
{
fakeOnMessageDismiss
}
/>
,
);
}
beforeEach
(()
=>
{
fakeOnMessageDismiss
=
sinon
.
stub
();
});
it
(
'should render a `ToastMessageItem` for each provided message'
,
()
=>
{
const
wrapper
=
createComponent
([
fakeSuccessMessage
(),
fakeErrorMessage
(),
fakeNoticeMessage
(),
]);
assert
.
lengthOf
(
wrapper
.
find
(
'ToastMessageItem'
),
3
);
});
describe
(
'`ToastMessageItem` sub-component'
,
()
=>
{
it
(
'should dismiss the message when clicked'
,
()
=>
{
const
wrapper
=
createComponent
([
fakeSuccessMessage
()]);
const
messageContainer
=
wrapper
.
find
(
'ToastMessageItem'
).
getDOMNode
();
act
(()
=>
{
messageContainer
.
dispatchEvent
(
new
Event
(
'click'
));
});
assert
.
calledOnce
(
fakeOnMessageDismiss
);
});
it
(
'should set a screen-reader-only class on `visuallyHidden` messages'
,
()
=>
{
const
message
=
fakeSuccessMessage
();
message
.
visuallyHidden
=
true
;
const
wrapper
=
createComponent
([
message
]);
const
messageContainer
=
wrapper
.
find
(
'ToastMessageItem'
).
getDOMNode
();
assert
.
include
(
messageContainer
.
className
,
'sr-only'
);
});
it
(
'should not dismiss the message if a "More info" link is clicked'
,
()
=>
{
const
wrapper
=
createComponent
([
fakeNoticeMessage
()]);
const
link
=
wrapper
.
find
(
'Link'
);
act
(()
=>
{
link
.
getDOMNode
().
dispatchEvent
(
new
Event
(
'click'
,
{
bubbles
:
true
}));
});
assert
.
notCalled
(
fakeOnMessageDismiss
);
});
[
{
message
:
fakeSuccessMessage
(),
prefix
:
'Success: '
},
{
message
:
fakeErrorMessage
(),
prefix
:
'Error: '
},
{
message
:
fakeNoticeMessage
(),
prefix
:
''
},
].
forEach
(
testCase
=>
{
it
(
'should prefix the message with the message type'
,
()
=>
{
const
wrapper
=
createComponent
([
testCase
.
message
]);
assert
.
include
(
wrapper
.
text
(),
`
${
testCase
.
prefix
}${
testCase
.
message
.
message
}
`
,
);
});
});
[
{
messages
:
[
fakeSuccessMessage
()],
icons
:
[
'CheckIcon'
]
},
{
messages
:
[
fakeErrorMessage
()],
icons
:
[
'CancelIcon'
]
},
{
messages
:
[
fakeNoticeMessage
()],
icons
:
[
'CautionIcon'
]
},
{
messages
:
[
fakeSuccessMessage
(),
fakeErrorMessage
()],
icons
:
[
'CheckIcon'
,
'CancelIcon'
],
},
].
forEach
(
testCase
=>
{
it
(
'should render an appropriate icon for the message type'
,
()
=>
{
const
wrapper
=
createComponent
(
testCase
.
messages
);
testCase
.
icons
.
forEach
(
iconName
=>
{
assert
.
isTrue
(
wrapper
.
find
(
iconName
).
exists
());
});
});
});
});
it
(
'should render a "more info" link if URL is present in message object'
,
()
=>
{
const
wrapper
=
createComponent
([
fakeNoticeMessage
()]);
const
link
=
wrapper
.
find
(
'Link'
);
assert
.
equal
(
link
.
props
().
href
,
'http://www.example.com'
);
assert
.
equal
(
link
.
text
(),
'More info'
);
});
describe
(
'a11y'
,
()
=>
{
it
(
'should pass a11y checks'
,
checkAccessibility
([
{
content
:
()
=>
createComponent
([
fakeSuccessMessage
(),
fakeErrorMessage
(),
fakeNoticeMessage
(),
]),
},
]),
);
});
});
src/shared/components/test/ToastMessages-test.js
0 → 100644
View file @
2fa444e0
import
{
mount
}
from
'enzyme'
;
import
ToastMessages
from
'../ToastMessages'
;
describe
(
'ToastMessages'
,
()
=>
{
const
toastMessages
=
[
{
id
:
'1'
,
type
:
'success'
,
message
:
'Hello world'
,
},
{
id
:
'2'
,
type
:
'success'
,
message
:
'Foobar'
,
},
{
id
:
'3'
,
type
:
'error'
,
message
:
'Something failed'
,
},
];
let
fakeOnMessageDismiss
;
beforeEach
(()
=>
{
fakeOnMessageDismiss
=
sinon
.
stub
();
});
function
createToastMessages
(
toastMessages
,
setTimeout
)
{
const
container
=
document
.
createElement
(
'div'
);
document
.
body
.
appendChild
(
container
);
return
mount
(
<
ToastMessages
messages
=
{
toastMessages
}
onMessageDismiss
=
{
fakeOnMessageDismiss
}
setTimeout_
=
{
setTimeout
}
/>
,
{
attachTo
:
container
},
);
}
function
triggerAnimationEnd
(
wrapper
,
index
,
direction
=
'out'
)
{
wrapper
.
find
(
'BaseToastMessageTransition'
)
.
at
(
index
)
.
props
()
.
onTransitionEnd
(
direction
);
wrapper
.
update
();
}
it
(
'renders a list of toast messages'
,
()
=>
{
const
wrapper
=
createToastMessages
(
toastMessages
);
assert
.
equal
(
wrapper
.
find
(
'ToastMessageItem'
).
length
,
toastMessages
.
length
);
});
toastMessages
.
forEach
((
message
,
index
)
=>
{
it
(
'dismisses messages when clicked'
,
()
=>
{
const
wrapper
=
createToastMessages
(
toastMessages
);
wrapper
.
find
(
'Callout'
).
at
(
index
).
props
().
onClick
();
// onMessageDismiss is not immediately called. Transition has to finish
assert
.
notCalled
(
fakeOnMessageDismiss
);
// Once dismiss animation has finished, onMessageDismiss is called
triggerAnimationEnd
(
wrapper
,
index
);
assert
.
calledWith
(
fakeOnMessageDismiss
,
message
.
id
);
});
});
it
(
'dismisses messages automatically unless instructed otherwise'
,
()
=>
{
const
messages
=
[
...
toastMessages
,
{
id
:
'foo'
,
type
:
'success'
,
message
:
'Not to be dismissed'
,
autoDismiss
:
false
,
},
];
const
wrapper
=
createToastMessages
(
messages
,
// Fake internal setTimeout, to immediately call its callback
callback
=>
callback
(),
);
// Trigger "in" animation for all messages, which will schedule dismiss for
// appropriate messages
messages
.
forEach
((
_
,
index
)
=>
{
triggerAnimationEnd
(
wrapper
,
index
,
'in'
);
});
// Trigger "out" animation on components which "direction" prop is currently
// "out". That means they were scheduled for dismiss
wrapper
.
find
(
'BaseToastMessageTransition'
)
.
forEach
((
transitionComponent
,
index
)
=>
{
if
(
transitionComponent
.
prop
(
'direction'
)
===
'out'
)
{
triggerAnimationEnd
(
wrapper
,
index
);
}
});
// Only one toast message will remain, as it was marked as `autoDismiss: false`
assert
.
equal
(
fakeOnMessageDismiss
.
callCount
,
3
);
});
it
(
'schedules dismiss only once per message'
,
async
()
=>
{
const
wrapper
=
createToastMessages
(
toastMessages
,
// Fake an immediate setTimeout which does not slow down the test, but
// keeps the async behavior
callback
=>
setTimeout
(
callback
,
0
),
);
const
scheduleFirstMessageDismiss
=
()
=>
triggerAnimationEnd
(
wrapper
,
0
,
'in'
);
scheduleFirstMessageDismiss
();
scheduleFirstMessageDismiss
();
scheduleFirstMessageDismiss
();
// Once dismiss animation has finished, onMessageDismiss is called
triggerAnimationEnd
(
wrapper
,
0
);
assert
.
equal
(
fakeOnMessageDismiss
.
callCount
,
1
);
});
it
(
'invokes onTransitionEnd when animation happens on container'
,
()
=>
{
const
wrapper
=
createToastMessages
(
toastMessages
,
callback
=>
callback
());
const
animationContainer
=
wrapper
.
find
(
'[data-testid="animation-container"]'
)
.
first
();
// Trigger "in" animation for all messages, which will schedule dismiss
toastMessages
.
forEach
((
_
,
index
)
=>
{
triggerAnimationEnd
(
wrapper
,
index
,
'in'
);
});
animationContainer
.
getDOMNode
()
.
dispatchEvent
(
new
AnimationEvent
(
'animationend'
));
assert
.
called
(
fakeOnMessageDismiss
);
});
it
(
'does not invoke onTransitionEnd for animation events bubbling from children'
,
()
=>
{
const
wrapper
=
createToastMessages
(
toastMessages
,
callback
=>
callback
());
const
invalidAnimationContainer
=
wrapper
.
find
(
'Callout'
).
first
();
// Trigger "in" animation for all messages, which will schedule dismiss
toastMessages
.
forEach
((
_
,
index
)
=>
{
triggerAnimationEnd
(
wrapper
,
index
,
'in'
);
});
invalidAnimationContainer
.
getDOMNode
()
.
dispatchEvent
(
new
AnimationEvent
(
'animationend'
,
{
bubbles
:
true
}));
assert
.
notCalled
(
fakeOnMessageDismiss
);
});
});
src/sidebar/components/ToastMessages.tsx
View file @
2fa444e0
import
BaseToastMessages
from
'../../shared/components/BaseToastMessages'
;
import
classnames
from
'classnames'
;
import
BaseToastMessages
from
'../../shared/components/ToastMessages'
;
import
{
withServices
}
from
'../service-context'
;
import
type
{
ToastMessengerService
}
from
'../services/toast-messenger'
;
import
{
useSidebarStore
}
from
'../store'
;
...
...
@@ -16,10 +18,22 @@ function ToastMessages({ toastMessenger }: ToastMessageProps) {
const
messages
=
store
.
getToastMessages
();
return
(
<
BaseToastMessages
messages=
{
messages
}
onMessageDismiss=
{
(
id
:
string
)
=>
toastMessenger
.
dismiss
(
id
)
}
/>
<
div
className=
{
classnames
(
// Ensure toast messages are rendered above other content
'z-10'
,
'absolute left-0 w-full'
,
)
}
>
<
BaseToastMessages
messages=
{
messages
}
onMessageDismiss=
{
(
id
:
string
)
=>
toastMessenger
.
dismiss
(
id
)
}
transitionClasses=
{
{
transitionIn
:
'motion-safe:animate-slide-in-from-right lg:animate-fade-in motion-reduce:animate-fade-in'
,
}
}
/>
</
div
>
);
}
...
...
src/sidebar/components/test/ToastMessages-test.js
View file @
2fa444e0
...
...
@@ -49,14 +49,14 @@ describe('ToastMessages', () => {
const
wrapper
=
createComponent
();
assert
.
lengthOf
(
wrapper
.
find
(
'
BaseToastMessages
'
).
prop
(
'messages'
),
3
);
assert
.
lengthOf
(
wrapper
.
find
(
'
[messages]
'
).
prop
(
'messages'
),
3
);
});
it
(
'should dismiss the message when clicked'
,
()
=>
{
fakeStore
.
getToastMessages
.
returns
([
fakeMessage
()]);
const
wrapper
=
createComponent
();
const
messageContainer
=
wrapper
.
find
(
'
BaseToastMessages
'
);
const
messageContainer
=
wrapper
.
find
(
'
[onMessageDismiss]
'
);
messageContainer
.
prop
(
'onMessageDismiss'
)();
...
...
src/sidebar/services/frame-sync.ts
View file @
2fa444e0
...
...
@@ -2,7 +2,7 @@ import debounce from 'lodash.debounce';
import
type
{
DebouncedFunction
}
from
'lodash.debounce'
;
import
shallowEqual
from
'shallowequal'
;
import
type
{
ToastMessage
}
from
'../../shared/components/
Base
ToastMessages'
;
import
type
{
ToastMessage
}
from
'../../shared/components/ToastMessages'
;
import
{
ListenerCollection
}
from
'../../shared/listener-collection'
;
import
{
PortFinder
,
...
...
src/sidebar/services/test/toast-messenger-test.js
View file @
2fa444e0
import
{
ToastMessengerService
}
from
'../toast-messenger'
;
describe
(
'ToastMessengerService'
,
()
=>
{
let
clock
;
let
fakeStore
;
let
fakeWindow
;
let
service
;
...
...
@@ -12,21 +11,15 @@ describe('ToastMessengerService', () => {
getToastMessage
:
sinon
.
stub
(),
hasToastMessage
:
sinon
.
stub
(),
removeToastMessage
:
sinon
.
stub
(),
updateToastMessage
:
sinon
.
stub
(),
};
fakeWindow
=
new
EventTarget
();
fakeWindow
.
document
=
{
hasFocus
:
sinon
.
stub
().
returns
(
true
),
};
clock
=
sinon
.
useFakeTimers
();
service
=
new
ToastMessengerService
(
fakeStore
,
fakeWindow
);
});
afterEach
(()
=>
{
clock
.
restore
();
});
describe
(
'#success'
,
()
=>
{
it
(
'does not add a new success message if a matching one already exists in the store'
,
()
=>
{
fakeStore
.
hasToastMessage
.
returns
(
true
);
...
...
@@ -56,35 +49,6 @@ describe('ToastMessengerService', () => {
);
});
it
(
'passes along `moreInfoURL` when present'
,
()
=>
{
fakeStore
.
hasToastMessage
.
returns
(
false
);
service
.
success
(
'hooray'
,
{
moreInfoURL
:
'http://www.example.com'
});
assert
.
calledWith
(
fakeStore
.
addToastMessage
,
sinon
.
match
({
type
:
'success'
,
message
:
'hooray'
,
moreInfoURL
:
'http://www.example.com'
,
}),
);
});
it
(
'dismisses the message after timeout fires'
,
()
=>
{
fakeStore
.
hasToastMessage
.
returns
(
false
);
fakeStore
.
getToastMessage
.
returns
(
undefined
);
service
.
success
(
'hooray'
);
// Move to the first scheduled timeout, which should invoke the
// `dismiss` method
clock
.
next
();
assert
.
calledOnce
(
fakeStore
.
getToastMessage
);
assert
.
notCalled
(
fakeStore
.
updateToastMessage
);
});
it
(
'emits "toastMessageAdded" event'
,
()
=>
{
fakeStore
.
hasToastMessage
.
returns
(
false
);
...
...
@@ -138,31 +102,14 @@ describe('ToastMessengerService', () => {
);
});
it
(
'dismisses the message after timeout fires'
,
()
=>
{
fakeStore
.
hasToastMessage
.
returns
(
false
);
fakeStore
.
getToastMessage
.
returns
(
undefined
);
service
.
error
(
'boo'
);
// Move to the first scheduled timeout, which should invoke the
// `dismiss` method
clock
.
next
();
assert
.
calledOnce
(
fakeStore
.
getToastMessage
);
assert
.
notCalled
(
fakeStore
.
updateToastMessage
);
});
it
(
'does not dismiss the message if `autoDismiss` is false'
,
()
=>
{
fakeStore
.
hasToastMessage
.
returns
(
false
);
fakeStore
.
getToastMessage
.
returns
(
undefined
);
service
.
error
(
'boo'
,
{
autoDismiss
:
false
});
// Move to the first scheduled timeout.
clock
.
next
();
assert
.
notCalled
(
fakeStore
.
getToastMessage
);
assert
.
notCalled
(
fakeStore
.
updat
eToastMessage
);
assert
.
notCalled
(
fakeStore
.
remov
eToastMessage
);
});
});
...
...
@@ -172,22 +119,10 @@ describe('ToastMessengerService', () => {
service
.
dismiss
(
'someid'
);
assert
.
notCalled
(
fakeStore
.
updateToastMessage
);
});
it
(
'does not dismiss a message if it is already dismissed'
,
()
=>
{
fakeStore
.
getToastMessage
.
returns
({
type
:
'success'
,
message
:
'yay'
,
isDismissed
:
true
,
});
service
.
dismiss
(
'someid'
);
assert
.
notCalled
(
fakeStore
.
updateToastMessage
);
assert
.
notCalled
(
fakeStore
.
removeToastMessage
);
});
it
(
'
updates the message object to set `isDimissed` to `true`
'
,
()
=>
{
it
(
'
removes the message from the store
'
,
()
=>
{
fakeStore
.
getToastMessage
.
returns
({
type
:
'success'
,
message
:
'yay'
,
...
...
@@ -196,24 +131,6 @@ describe('ToastMessengerService', () => {
service
.
dismiss
(
'someid'
);
assert
.
calledWith
(
fakeStore
.
updateToastMessage
,
sinon
.
match
({
isDismissed
:
true
}),
);
});
it
(
'removes the message from the store after timeout fires'
,
()
=>
{
fakeStore
.
getToastMessage
.
returns
({
type
:
'success'
,
message
:
'yay'
,
isDismissed
:
false
,
});
service
.
dismiss
(
'someid'
);
// Advance the clock to fire the timeout that will remove the message
clock
.
next
();
assert
.
calledOnce
(
fakeStore
.
removeToastMessage
);
assert
.
calledWith
(
fakeStore
.
removeToastMessage
,
'someid'
);
});
...
...
src/sidebar/services/toast-messenger.ts
View file @
2fa444e0
import
{
TinyEmitter
}
from
'tiny-emitter'
;
import
type
{
ToastMessage
}
from
'../../shared/components/ToastMessages'
;
import
{
generateHexString
}
from
'../../shared/random'
;
import
type
{
SidebarStore
}
from
'../store'
;
// How long toast messages should be displayed before they are dismissed, in ms
const
MESSAGE_DISPLAY_TIME
=
5000
;
// Delay before removing the message entirely (allows animations to complete)
const
MESSAGE_DISMISS_DELAY
=
500
;
/**
* Additional control over the display of a particular message.
*/
export
type
MessageOptions
=
{
/** Whether the toast message automatically disappears. */
autoDismiss
?:
boolean
;
/** Optional URL for users to visit for "more info" */
moreInfoURL
?:
string
;
/**
* When `true`, message will be visually hidden but still available to screen
...
...
@@ -41,9 +35,8 @@ type MessageData = {
};
/**
* A service for managing toast messages. The service will auto-dismiss and
* remove toast messages created with `#success()` or `#error()`. Added
* messages may be manually dismissed with the `#dismiss()` method.
* A service for managing toast messages. Added messages may be manually
* dismissed with the `#dismiss()` method.
*/
// @inject
export
class
ToastMessengerService
extends
TinyEmitter
{
...
...
@@ -81,12 +74,9 @@ export class ToastMessengerService extends TinyEmitter {
*/
dismiss
(
messageId
:
string
)
{
const
message
=
this
.
_store
.
getToastMessage
(
messageId
);
if
(
message
&&
!
message
.
isDismissed
)
{
this
.
_store
.
updateToastMessage
({
...
message
,
isDismissed
:
true
}
);
if
(
message
)
{
this
.
_store
.
removeToastMessage
(
messageId
);
this
.
emit
(
'toastMessageDismissed'
,
messageId
);
setTimeout
(()
=>
{
this
.
_store
.
removeToastMessage
(
messageId
);
},
MESSAGE_DISMISS_DELAY
);
}
}
...
...
@@ -101,7 +91,6 @@ export class ToastMessengerService extends TinyEmitter {
messageText
:
string
,
{
autoDismiss
=
true
,
moreInfoURL
=
''
,
visuallyHidden
=
false
,
delayed
=
false
,
}:
MessageOptions
=
{},
...
...
@@ -114,34 +103,21 @@ export class ToastMessengerService extends TinyEmitter {
if
(
delayed
&&
!
this
.
_window
.
document
.
hasFocus
())
{
// Ignore the "delayed" option to avoid an infinite loop of re-enqueuing
// the same messages over and over
const
options
=
{
autoDismiss
,
moreInfoURL
,
visuallyHidden
};
const
options
=
{
autoDismiss
,
visuallyHidden
};
this
.
_delayedMessageQueue
.
push
({
type
,
messageText
,
options
});
return
;
}
const
id
=
generateHexString
(
10
);
const
message
=
{
const
message
:
ToastMessage
=
{
type
,
id
,
message
:
messageText
,
moreInfoURL
,
visuallyHidden
,
};
this
.
_store
.
addToastMessage
({
isDismissed
:
false
,
...
message
,
});
this
.
_store
.
addToastMessage
(
message
);
this
.
emit
(
'toastMessageAdded'
,
message
);
if
(
autoDismiss
)
{
// Attempt to dismiss message after a set time period. NB: The message may
// have been removed by other mechanisms at this point; do not assume its
// presence.
setTimeout
(()
=>
{
this
.
dismiss
(
id
);
},
MESSAGE_DISPLAY_TIME
);
}
}
/**
...
...
src/sidebar/store/modules/test/toast-messages-test.js
View file @
2fa444e0
...
...
@@ -66,27 +66,6 @@ describe('store/modules/toast-messages', () => {
assert
.
lengthOf
(
store
.
getToastMessages
(),
2
);
});
});
describe
(
'updateToastMessage'
,
()
=>
{
it
(
'should update the message object'
,
()
=>
{
const
updatedMessage
=
{
id
:
'myToast'
,
type
:
'whatever'
,
message
:
'updated'
,
};
store
.
addToastMessage
(
fakeToastMessage
);
store
.
updateToastMessage
(
updatedMessage
);
assert
.
deepEqual
(
store
.
getToastMessage
(
'myToast'
),
updatedMessage
);
});
it
(
'should be OK if there is no matching message object'
,
()
=>
{
store
.
addToastMessage
(
fakeToastMessage
);
store
.
updateToastMessage
({
id
:
'random'
});
assert
.
lengthOf
(
store
.
getToastMessages
(),
1
);
});
});
});
describe
(
'selectors'
,
()
=>
{
...
...
src/sidebar/store/modules/toast-messages.ts
View file @
2fa444e0
import
type
{
ToastMessage
}
from
'../../../shared/components/
Base
ToastMessages'
;
import
type
{
ToastMessage
}
from
'../../../shared/components/ToastMessages'
;
import
{
createStoreModule
,
makeAction
}
from
'../create-store'
;
/**
...
...
@@ -28,16 +28,6 @@ const reducers = {
);
return
{
messages
:
updatedMessages
};
},
UPDATE_MESSAGE
(
state
:
State
,
action
:
{
message
:
ToastMessage
})
{
const
updatedMessages
=
state
.
messages
.
map
(
message
=>
{
if
(
message
.
id
&&
message
.
id
===
action
.
message
.
id
)
{
return
{
...
action
.
message
};
}
return
message
;
});
return
{
messages
:
updatedMessages
};
},
};
/** Actions */
...
...
@@ -53,13 +43,6 @@ function removeMessage(id: string) {
return
makeAction
(
reducers
,
'REMOVE_MESSAGE'
,
{
id
});
}
/**
* Update the `message` object (lookup is by `id`).
*/
function
updateMessage
(
message
:
ToastMessage
)
{
return
makeAction
(
reducers
,
'UPDATE_MESSAGE'
,
{
message
});
}
/** Selectors */
/**
...
...
@@ -93,7 +76,6 @@ export const toastMessagesModule = createStoreModule(initialState, {
actionCreators
:
{
addToastMessage
:
addMessage
,
removeToastMessage
:
removeMessage
,
updateToastMessage
:
updateMessage
,
},
selectors
:
{
getToastMessage
:
getMessage
,
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment