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
5aa494bf
Commit
5aa494bf
authored
Mar 20, 2020
by
Lyza Danger Gardner
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Re-implement toast/flash messaging
* preact-compatible * more accessible
parent
5c466405
Changes
14
Hide whitespace changes
Inline
Side-by-side
Showing
14 changed files
with
856 additions
and
0 deletions
+856
-0
check.svg
src/images/icons/check.svg
+1
-0
svg-icon.js
src/sidebar/components/svg-icon.js
+2
-0
toast-messages-test.js
src/sidebar/components/test/toast-messages-test.js
+168
-0
toast-messages.js
src/sidebar/components/toast-messages.js
+92
-0
index.js
src/sidebar/index.js
+5
-0
toast-messenger-test.js
src/sidebar/services/test/toast-messenger-test.js
+157
-0
toast-messenger.js
src/sidebar/services/toast-messenger.js
+79
-0
index.js
src/sidebar/store/index.js
+2
-0
toast-messages-test.js
src/sidebar/store/modules/test/toast-messages-test.js
+143
-0
toast-messages.js
src/sidebar/store/modules/toast-messages.js
+115
-0
hypothesis-app.html
src/sidebar/templates/hypothesis-app.html
+1
-0
toast-messages.scss
src/styles/sidebar/components/toast-messages.scss
+87
-0
sidebar.scss
src/styles/sidebar/sidebar.scss
+1
-0
variables.scss
src/styles/variables.scss
+3
-0
No files found.
src/images/icons/check.svg
0 → 100644
View file @
5aa494bf
<svg
xmlns=
"http://www.w3.org/2000/svg"
width=
"16"
height=
"16"
viewBox=
"0 0 16 16"
class=
"Icon Icon--check"
><g
fill-rule=
"evenodd"
><rect
fill=
"none"
stroke=
"none"
x=
"0"
y=
"0"
width=
"16"
height=
"16"
></rect><path
fill=
"none"
stroke=
"currentColor"
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M13 3L6 13 3 8"
></path></g></svg>
src/sidebar/components/svg-icon.js
View file @
5aa494bf
...
...
@@ -19,6 +19,7 @@ const icons = {
edit
:
require
(
'../../images/icons/edit.svg'
),
email
:
require
(
'../../images/icons/email.svg'
),
'expand-menu'
:
require
(
'../../images/icons/expand-menu.svg'
),
error
:
require
(
'../../images/icons/cancel.svg'
),
external
:
require
(
'../../images/icons/external.svg'
),
facebook
:
require
(
'../../images/icons/facebook.svg'
),
flag
:
require
(
'../../images/icons/flag.svg'
),
...
...
@@ -45,6 +46,7 @@ const icons = {
reply
:
require
(
'../../images/icons/reply.svg'
),
search
:
require
(
'../../images/icons/search.svg'
),
share
:
require
(
'../../images/icons/share.svg'
),
success
:
require
(
'../../images/icons/check.svg'
),
sort
:
require
(
'../../images/icons/sort.svg'
),
trash
:
require
(
'../../images/icons/trash.svg'
),
twitter
:
require
(
'../../images/icons/twitter.svg'
),
...
...
src/sidebar/components/test/toast-messages-test.js
0 → 100644
View file @
5aa494bf
import
{
mount
}
from
'enzyme'
;
import
{
createElement
}
from
'preact'
;
import
{
act
}
from
'preact/test-utils'
;
import
mockImportedComponents
from
'../../../test-util/mock-imported-components'
;
import
ToastMessages
,
{
$imports
}
from
'../toast-messages'
;
import
{
checkAccessibility
}
from
'../../../test-util/accessibility'
;
describe
(
'ToastMessages'
,
()
=>
{
let
fakeStore
;
let
fakeToastMessenger
;
let
fakeErrorMessage
=
()
=>
{
return
{
type
:
'error'
,
message
:
'boo'
,
id
:
'someid2'
,
isDismissed
:
false
,
};
};
let
fakeSuccessMessage
=
()
=>
{
return
{
type
:
'success'
,
message
:
'yay'
,
id
:
'someid'
,
isDismissed
:
false
,
};
};
function
createComponent
(
props
)
{
return
mount
(
<
ToastMessages
toastMessenger
=
{
fakeToastMessenger
}
{...
props
}
/
>
);
}
beforeEach
(()
=>
{
fakeStore
=
{
getToastMessages
:
sinon
.
stub
(),
};
fakeToastMessenger
=
{
dismiss
:
sinon
.
stub
(),
};
$imports
.
$mock
(
mockImportedComponents
());
$imports
.
$mock
({
'../store/use-store'
:
callback
=>
callback
(
fakeStore
),
});
});
afterEach
(()
=>
{
$imports
.
$restore
();
});
it
(
'should render a `ToastMessage` for each message returned by the store'
,
()
=>
{
fakeStore
.
getToastMessages
.
returns
([
fakeSuccessMessage
(),
fakeErrorMessage
(),
]);
const
wrapper
=
createComponent
();
assert
.
lengthOf
(
wrapper
.
find
(
'ToastMessage'
),
2
);
});
describe
(
'`ToastMessage` sub-component'
,
()
=>
{
it
(
'should add `is-dismissed` stateful class name if message has been dismissed'
,
()
=>
{
const
message
=
fakeSuccessMessage
();
message
.
isDismissed
=
true
;
fakeStore
.
getToastMessages
.
returns
([
message
]);
const
wrapper
=
createComponent
();
const
messageContainer
=
wrapper
.
find
(
'ToastMessage li'
);
assert
.
isTrue
(
messageContainer
.
hasClass
(
'is-dismissed'
));
});
it
(
'should dismiss the message when clicked'
,
()
=>
{
fakeStore
.
getToastMessages
.
returns
([
fakeSuccessMessage
()]);
const
wrapper
=
createComponent
();
const
messageContainer
=
wrapper
.
find
(
'ToastMessage li'
);
act
(()
=>
{
messageContainer
.
simulate
(
'click'
);
});
assert
.
calledOnce
(
fakeToastMessenger
.
dismiss
);
});
[
{
message
:
fakeSuccessMessage
(),
className
:
'toast-message--success'
},
{
message
:
fakeErrorMessage
(),
className
:
'toast-message--error'
},
].
forEach
(
testCase
=>
{
it
(
'should assign a CSS class based on message type'
,
()
=>
{
fakeStore
.
getToastMessages
.
returns
([
testCase
.
message
]);
const
wrapper
=
createComponent
();
const
messageWrapper
=
wrapper
.
find
(
'.toast-message'
);
assert
.
isTrue
(
messageWrapper
.
hasClass
(
testCase
.
className
));
});
[
{
message
:
fakeSuccessMessage
(),
prefix
:
'Success'
},
{
message
:
fakeErrorMessage
(),
prefix
:
'Error'
},
].
forEach
(
testCase
=>
{
it
(
'should prefix the message with the message type'
,
()
=>
{
fakeStore
.
getToastMessages
.
returns
([
testCase
.
message
]);
const
wrapper
=
createComponent
();
const
messageContent
=
wrapper
.
find
(
'.toast-message__message'
)
.
first
();
assert
.
equal
(
messageContent
.
text
(),
`
${
testCase
.
prefix
}
:
${
testCase
.
message
.
message
}
`
);
});
});
});
[
{
messages
:
[
fakeSuccessMessage
()],
icons
:
[
'success'
]
},
{
messages
:
[
fakeErrorMessage
()],
icons
:
[
'error'
]
},
{
messages
:
[
fakeSuccessMessage
(),
fakeErrorMessage
()],
icons
:
[
'success'
,
'error'
],
},
].
forEach
(
testCase
=>
{
it
(
'should render an appropriate icon for the message type'
,
()
=>
{
fakeStore
.
getToastMessages
.
returns
(
testCase
.
messages
);
const
wrapper
=
createComponent
();
const
iconProps
=
wrapper
.
find
(
'SvgIcon'
)
.
map
(
iconWrapper
=>
iconWrapper
.
props
().
name
);
assert
.
deepEqual
(
iconProps
,
testCase
.
icons
);
});
});
});
describe
(
'a11y'
,
()
=>
{
beforeEach
(()
=>
{
fakeStore
.
getToastMessages
.
returns
([
fakeSuccessMessage
(),
fakeErrorMessage
(),
]);
});
it
(
'should pass a11y checks'
,
checkAccessibility
([
{
content
:
()
=>
createComponent
(),
},
])
);
});
});
src/sidebar/components/toast-messages.js
0 → 100644
View file @
5aa494bf
import
classnames
from
'classnames'
;
import
{
createElement
}
from
'preact'
;
import
propTypes
from
'prop-types'
;
import
useStore
from
'../store/use-store'
;
import
{
withServices
}
from
'../util/service-context'
;
import
SvgIcon
from
'./svg-icon'
;
/**
* An individual toast message—a brief and transient success or error message.
* The message may be dismissed by clicking on it.
* Otherwise, the `toastMessenger` service handles removing messages after a
* certain amount of time.
*/
function
ToastMessage
({
message
,
onDismiss
})
{
// Capitalize the message type for prepending
const
prefix
=
message
.
type
.
charAt
(
0
).
toUpperCase
()
+
message
.
type
.
slice
(
1
);
/**
* a11y linting is disabled here: 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
(
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */
<
li
className
=
{
classnames
(
'toast-message-container'
,
{
'is-dismissed'
:
message
.
isDismissed
,
})}
onClick
=
{()
=>
onDismiss
(
message
.
id
)}
>
<
div
className
=
{
classnames
(
'toast-message'
,
`toast-message--
${
message
.
type
}
`
)}
>
<
div
className
=
"toast-message__type"
>
<
SvgIcon
name
=
{
message
.
type
}
className
=
"toast-message__icon"
/>
<
/div
>
<
div
className
=
"toast-message__message"
>
<
strong
>
{
prefix
}:
<
/strong
>
{
message
.
message
}
<
/div
>
<
/div
>
<
/li
>
);
}
ToastMessage
.
propTypes
=
{
// The message object to render
message
:
propTypes
.
object
.
isRequired
,
onDismiss
:
propTypes
.
func
,
};
/**
* A collection of toast messages. These are rendered within an `aria-live`
* region for accessibility with screen readers.
*/
function
ToastMessages
({
toastMessenger
})
{
const
messages
=
useStore
(
store
=>
store
.
getToastMessages
());
return
(
<
div
>
<
ul
aria
-
live
=
"polite"
aria
-
relevant
=
"additions"
className
=
"toast-messages"
>
{
messages
.
map
(
message
=>
(
<
ToastMessage
message
=
{
message
}
key
=
{
message
.
id
}
onDismiss
=
{
toastMessenger
.
dismiss
}
/
>
))}
<
/ul
>
<
/div
>
);
}
ToastMessages
.
propTypes
=
{
// Injected services
toastMessenger
:
propTypes
.
object
.
isRequired
,
};
ToastMessages
.
injectedProps
=
[
'toastMessenger'
];
export
default
withServices
(
ToastMessages
);
src/sidebar/index.js
View file @
5aa494bf
...
...
@@ -152,6 +152,7 @@ import SidebarContentError from './components/sidebar-content-error';
import
SvgIcon
from
'./components/svg-icon'
;
import
TagEditor
from
'./components/tag-editor'
;
import
TagList
from
'./components/tag-list'
;
import
ToastMessages
from
'./components/toast-messages'
;
import
TopBar
from
'./components/top-bar'
;
// Remaining UI components that are still built with Angular.
...
...
@@ -189,6 +190,7 @@ import sessionService from './services/session';
import
streamFilterService
from
'./services/stream-filter'
;
import
streamerService
from
'./services/streamer'
;
import
tagsService
from
'./services/tags'
;
import
toastMessenger
from
'./services/toast-messenger'
;
import
unicodeService
from
'./services/unicode'
;
import
viewFilterService
from
'./services/view-filter'
;
...
...
@@ -232,6 +234,7 @@ function startAngularApp(config) {
.
register
(
'streamer'
,
streamerService
)
.
register
(
'streamFilter'
,
streamFilterService
)
.
register
(
'tags'
,
tagsService
)
.
register
(
'toastMessenger'
,
toastMessenger
)
.
register
(
'unicode'
,
unicodeService
)
.
register
(
'viewFilter'
,
viewFilterService
)
.
register
(
'store'
,
store
);
...
...
@@ -292,6 +295,7 @@ function startAngularApp(config) {
.
component
(
'tagEditor'
,
wrapComponent
(
TagEditor
))
.
component
(
'tagList'
,
wrapComponent
(
TagList
))
.
component
(
'threadList'
,
threadList
)
.
component
(
'toastMessages'
,
wrapComponent
(
ToastMessages
))
.
component
(
'topBar'
,
wrapComponent
(
TopBar
))
// Register services, the store and utilities with Angular, so that
...
...
@@ -318,6 +322,7 @@ function startAngularApp(config) {
.
service
(
'session'
,
()
=>
container
.
get
(
'session'
))
.
service
(
'streamer'
,
()
=>
container
.
get
(
'streamer'
))
.
service
(
'streamFilter'
,
()
=>
container
.
get
(
'streamFilter'
))
.
service
(
'toastMessenger'
,
()
=>
container
.
get
(
'toastMessenger'
))
// Redux store
.
service
(
'store'
,
()
=>
container
.
get
(
'store'
))
...
...
src/sidebar/services/test/toast-messenger-test.js
0 → 100644
View file @
5aa494bf
import
toastMessenger
from
'../toast-messenger'
;
describe
(
'toastMessenger'
,
function
()
{
let
clock
;
let
fakeStore
;
let
service
;
beforeEach
(()
=>
{
fakeStore
=
{
addToastMessage
:
sinon
.
stub
(),
getToastMessage
:
sinon
.
stub
(),
hasToastMessage
:
sinon
.
stub
(),
removeToastMessage
:
sinon
.
stub
(),
updateToastMessage
:
sinon
.
stub
(),
};
clock
=
sinon
.
useFakeTimers
();
service
=
toastMessenger
(
fakeStore
);
});
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
);
service
.
success
(
'This is my message'
);
assert
.
calledWith
(
fakeStore
.
hasToastMessage
,
'success'
,
'This is my message'
);
assert
.
notCalled
(
fakeStore
.
addToastMessage
);
});
it
(
'adds a new success toast message to the store'
,
()
=>
{
fakeStore
.
hasToastMessage
.
returns
(
false
);
service
.
success
(
'hooray'
);
assert
.
calledWith
(
fakeStore
.
addToastMessage
,
sinon
.
match
({
type
:
'success'
,
message
:
'hooray'
})
);
});
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
);
});
});
describe
(
'#error'
,
()
=>
{
it
(
'does not add a new error message if one with the same message text already exists'
,
()
=>
{
fakeStore
.
hasToastMessage
.
returns
(
true
);
service
.
error
(
'This is my message'
);
assert
.
calledWith
(
fakeStore
.
hasToastMessage
,
'error'
,
'This is my message'
);
assert
.
notCalled
(
fakeStore
.
addToastMessage
);
});
it
(
'adds a new error toast message to the store'
,
()
=>
{
fakeStore
.
hasToastMessage
.
returns
(
false
);
service
.
error
(
'boo'
);
assert
.
calledWith
(
fakeStore
.
addToastMessage
,
sinon
.
match
({
type
:
'error'
,
message
:
'boo'
})
);
});
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
);
});
});
describe
(
'#dismiss'
,
()
=>
{
it
(
'does not dismiss the message if it does not exist'
,
()
=>
{
fakeStore
.
getToastMessage
.
returns
(
undefined
);
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
);
});
it
(
'updates the message object to set `isDimissed` to `true`'
,
()
=>
{
fakeStore
.
getToastMessage
.
returns
({
type
:
'success'
,
message
:
'yay'
,
isDismissed
:
false
,
});
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.js
0 → 100644
View file @
5aa494bf
/**
* 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.
*/
import
{
generateHexString
}
from
'../util/random'
;
// 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
;
// @ngInject
export
default
function
toastMessenger
(
store
)
{
/**
* Update a toast message's dismiss status and set a timeout to remove
* it after a bit. This allows effects/animations to happen before the
* message is removed entirely.
*
* @param {string} messageId - The value of the `id` property for the
* message that is to be dismissed.
*/
const
dismiss
=
messageId
=>
{
const
message
=
store
.
getToastMessage
(
messageId
);
if
(
message
&&
!
message
.
isDismissed
)
{
store
.
updateToastMessage
({
...
message
,
isDismissed
:
true
});
setTimeout
(()
=>
{
store
.
removeToastMessage
(
messageId
);
},
MESSAGE_DISMISS_DELAY
);
}
};
/**
* Add a new toast message to the store and set a timeout to dismiss it
* after some time. This method will not add a message that is already
* extant in the store's collection of toast messages (i.e. has the same
* `type` and `message` text of an existing message).
*
* @param {('error'|'success')} type
* @param {string} message - The message to be rendered
*/
const
addMessage
=
(
type
,
message
)
=>
{
// Do not add duplicate messages (messages with the same type and text)
if
(
store
.
hasToastMessage
(
type
,
message
))
{
return
;
}
const
id
=
generateHexString
(
10
);
store
.
addToastMessage
({
type
,
message
,
id
,
isDismissed
:
false
});
// 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
(()
=>
{
dismiss
(
id
);
},
MESSAGE_DISPLAY_TIME
);
};
/**
* Add an error toast message with `messageText`
*/
const
error
=
messageText
=>
{
addMessage
(
'error'
,
messageText
);
};
/**
* Add a success toast message with `messageText`
*/
const
success
=
messageText
=>
{
addMessage
(
'success'
,
messageText
);
};
return
{
dismiss
,
error
,
success
,
};
}
src/sidebar/store/index.js
View file @
5aa494bf
...
...
@@ -43,6 +43,7 @@ import realTimeUpdates from './modules/real-time-updates';
import
selection
from
'./modules/selection'
;
import
session
from
'./modules/session'
;
import
sidebarPanels
from
'./modules/sidebar-panels'
;
import
toastMessages
from
'./modules/toast-messages'
;
import
viewer
from
'./modules/viewer'
;
/**
...
...
@@ -98,6 +99,7 @@ export default function store($rootScope, settings) {
selection
,
session
,
sidebarPanels
,
toastMessages
,
viewer
,
];
return
createStore
(
modules
,
[
settings
],
middleware
);
...
...
src/sidebar/store/modules/test/toast-messages-test.js
0 → 100644
View file @
5aa494bf
import
createStore
from
'../../create-store'
;
import
toastMessages
from
'../toast-messages'
;
describe
(
'store/modules/toast-messages'
,
function
()
{
let
store
;
let
fakeToastMessage
;
beforeEach
(()
=>
{
store
=
createStore
([
toastMessages
]);
fakeToastMessage
=
{
id
:
'myToast'
,
type
:
'anyType'
,
message
:
'This is a message'
,
};
});
describe
(
'actions'
,
()
=>
{
describe
(
'addToastMessage'
,
()
=>
{
it
(
'adds the provided object to the array of messages in state'
,
()
=>
{
store
.
addToastMessage
(
fakeToastMessage
);
const
messages
=
store
.
getToastMessages
();
assert
.
lengthOf
(
messages
,
1
);
// These two objects should not have the same reference
assert
.
notEqual
(
messages
[
0
],
fakeToastMessage
);
assert
.
equal
(
messages
[
0
].
id
,
'myToast'
);
assert
.
equal
(
messages
[
0
].
type
,
'anyType'
);
assert
.
equal
(
messages
[
0
].
message
,
'This is a message'
);
});
it
(
'adds duplicate messages to the array of messages in state'
,
()
=>
{
// This store module doesn't care about duplicates
store
.
addToastMessage
(
fakeToastMessage
);
store
.
addToastMessage
(
fakeToastMessage
);
const
messages
=
store
.
getToastMessages
();
assert
.
lengthOf
(
messages
,
2
);
assert
.
notEqual
(
messages
[
0
],
messages
[
1
]);
});
});
describe
(
'removeToastMessage'
,
()
=>
{
it
(
'removes messages that match the provided id'
,
()
=>
{
store
.
addToastMessage
(
fakeToastMessage
);
fakeToastMessage
.
id
=
'myToast2'
;
store
.
addToastMessage
(
fakeToastMessage
);
store
.
removeToastMessage
(
'myToast2'
);
const
messages
=
store
.
getToastMessages
();
assert
.
lengthOf
(
messages
,
1
);
assert
.
equal
(
messages
[
0
].
id
,
'myToast'
);
});
it
(
'should not remove any objects if none match the provided id'
,
()
=>
{
store
.
addToastMessage
(
fakeToastMessage
);
fakeToastMessage
.
id
=
'myToast2'
;
store
.
addToastMessage
(
fakeToastMessage
);
store
.
removeToastMessage
(
'myToast3'
);
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'
,
()
=>
{
describe
(
'getToastMessage'
,
()
=>
{
it
(
'should return message with matching `id`'
,
()
=>
{
const
anotherMessage
=
{
...
fakeToastMessage
,
id
:
'thisOne'
};
store
.
addToastMessage
(
fakeToastMessage
);
store
.
addToastMessage
(
anotherMessage
);
const
retrievedMessage
=
store
.
getToastMessage
(
'thisOne'
);
assert
.
deepEqual
(
retrievedMessage
,
anotherMessage
);
});
it
(
'should return `undefined` if no message matches'
,
()
=>
{
store
.
addToastMessage
(
fakeToastMessage
);
assert
.
isUndefined
(
store
.
getToastMessage
(
'someRandomId'
));
});
});
describe
(
'getToastMessages'
,
()
=>
{
it
(
'should return its collection of messages'
,
()
=>
{
assert
.
isArray
(
store
.
getToastMessages
());
});
});
describe
(
'hasToastMessage'
,
()
=>
{
it
(
'should return `true` if one message matches `type` and `state`'
,
()
=>
{
store
.
addToastMessage
(
fakeToastMessage
);
fakeToastMessage
.
type
=
'anotherType'
;
store
.
addToastMessage
(
fakeToastMessage
);
assert
.
isTrue
(
store
.
hasToastMessage
(
'anyType'
,
'This is a message'
));
});
it
(
'should return `true` if more than one message matches `type` and `state`'
,
()
=>
{
store
.
addToastMessage
(
fakeToastMessage
);
store
.
addToastMessage
(
fakeToastMessage
);
assert
.
isTrue
(
store
.
hasToastMessage
(
'anyType'
,
'This is a message'
));
});
it
(
'should return `false` if no messages match both `type` and `state`'
,
()
=>
{
store
.
addToastMessage
(
fakeToastMessage
);
assert
.
isFalse
(
store
.
hasToastMessage
(
'anotherType'
,
'This is a message'
)
);
assert
.
isFalse
(
store
.
hasToastMessage
(
'anyType'
,
'This is another message'
)
);
});
});
});
});
src/sidebar/store/modules/toast-messages.js
0 → 100644
View file @
5aa494bf
import
*
as
util
from
'../util'
;
/**
* A store module for managing a collection of toast messages. This module
* maintains state only; it's up to other layers to handle the management
* and interactions with these messages.
*/
function
init
()
{
return
{
messages
:
[],
};
}
const
update
=
{
ADD_MESSAGE
:
function
(
state
,
action
)
{
return
{
messages
:
state
.
messages
.
concat
({
...
action
.
message
}),
};
},
REMOVE_MESSAGE
:
function
(
state
,
action
)
{
const
updatedMessages
=
state
.
messages
.
filter
(
message
=>
message
.
id
!==
action
.
id
);
return
{
messages
:
updatedMessages
};
},
UPDATE_MESSAGE
:
function
(
state
,
action
)
{
const
updatedMessages
=
state
.
messages
.
map
(
message
=>
{
if
(
message
.
id
&&
message
.
id
===
action
.
message
.
id
)
{
return
{
...
action
.
message
};
}
return
message
;
});
return
{
messages
:
updatedMessages
};
},
};
const
actions
=
util
.
actionTypes
(
update
);
/** Actions */
function
addMessage
(
message
)
{
return
{
type
:
actions
.
ADD_MESSAGE
,
message
};
}
/**
* Remove the `message` with the corresponding `id` property value.
*
* @param {string} id
*/
function
removeMessage
(
id
)
{
return
{
type
:
actions
.
REMOVE_MESSAGE
,
id
};
}
/**
* Update the `message` object (lookup is by `id`).
*
* @param {Object} message
*/
function
updateMessage
(
message
)
{
return
{
type
:
actions
.
UPDATE_MESSAGE
,
message
};
}
/** Selectors */
/**
* Retrieve a message by `id`
*
* @param {string} id
* @return {Object|undefined}
*/
function
getMessage
(
state
,
id
)
{
return
state
.
toastMessages
.
messages
.
find
(
message
=>
message
.
id
===
id
);
}
/**
* Retrieve all current messages
*
* @return {Object[]}
*/
function
getMessages
(
state
)
{
return
state
.
toastMessages
.
messages
;
}
/**
* Return boolean indicating whether a message with the same type and message
* text exists in the state's collection of messages. This matches messages
* by content, not by ID (true uniqueness).
*
* @param {string} type
* @param {string} text
* @return {boolean}
*/
function
hasMessage
(
state
,
type
,
text
)
{
return
state
.
toastMessages
.
messages
.
some
(
message
=>
{
return
message
.
type
===
type
&&
message
.
message
===
text
;
});
}
export
default
{
init
,
namespace
:
'toastMessages'
,
update
,
actions
:
{
addToastMessage
:
addMessage
,
removeToastMessage
:
removeMessage
,
updateToastMessage
:
updateMessage
,
},
selectors
:
{
getToastMessage
:
getMessage
,
getToastMessages
:
getMessages
,
hasToastMessage
:
hasMessage
,
},
};
src/sidebar/templates/hypothesis-app.html
View file @
5aa494bf
...
...
@@ -8,6 +8,7 @@
</top-bar>
<div
class=
"content"
>
<toast-messages></toast-messages>
<help-panel
auth=
"vm.auth"
></help-panel>
<share-annotations-panel></share-annotations-panel>
<main
ng-view=
""
></main>
...
...
src/styles/sidebar/components/toast-messages.scss
0 → 100644
View file @
5aa494bf
@use
"../../mixins/panel"
;
@use
"../../variables"
as
var
;
.toast-messages
{
position
:
absolute
;
z-index
:
1
;
width
:
100%
;
left
:
0
;
padding
:
0
8px
;
}
.toast-message-container
{
position
:
relative
;
width
:
100%
;
animation
:
slidein
0
.3s
forwards
ease-in-out
;
&
:hover
{
cursor
:
pointer
;
}
&
.is-dismissed
{
animation
:
fadeout
0
.3s
forwards
;
}
}
.toast-message
{
@include
panel
.
panel
;
display
:
flex
;
position
:
relative
;
width
:
100%
;
box-shadow
:
0px
2px
3px
0px
rgba
(
0
,
0
,
0
,
0
.15
);
border-radius
:
2px
;
margin-bottom
:
0
.75em
;
&
--success
{
border
:
1px
solid
var
.
$color-success
;
}
&
--error
{
border
:
1px
solid
var
.
$color-error
;
}
&
__type
{
padding
:
1em
;
color
:
white
;
}
&
--success
&
__type
{
background-color
:
var
.
$color-success
;
}
&
--error
&
__type
{
background-color
:
var
.
$color-error
;
}
&
__icon
{
// Specific adjustments for success and error icons
margin-top
:
2px
;
}
&
__message
{
padding
:
1em
;
}
}
@keyframes
slidein
{
0
%
{
opacity
:
0
;
left
:
100%
;
}
80
%
{
left
:
-10px
;
}
100
%
{
left
:
0
;
opacity
:
1
;
}
}
@keyframes
fadeout
{
from
{
opacity
:
1
;
}
to
{
opacity
:
0
;
}
}
src/styles/sidebar/sidebar.scss
View file @
5aa494bf
...
...
@@ -63,6 +63,7 @@
@use
'./components/tag-editor'
;
@use
'./components/tag-list'
;
@use
'./components/thread-list'
;
@use
'./components/toast-messages'
;
@use
'./components/tooltip'
;
@use
'./components/top-bar'
;
@use
'./components/tutorial'
;
...
...
src/styles/variables.scss
View file @
5aa494bf
...
...
@@ -38,6 +38,9 @@ $black: #000 !default;
// Colors
$color-success
:
#00a36d
;
$color-warning
:
#fbc168
;
$color-error
:
#d93c3f
;
$brand
:
#bd1c2b
;
$highlight
:
#58cef4
;
...
...
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