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
c24514cb
Commit
c24514cb
authored
Mar 22, 2023
by
Alejandro Celaya
Committed by
Alejandro Celaya
Mar 27, 2023
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Extract reusable BaseToastMessages component
parent
ac71acc0
Changes
4
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
324 additions
and
271 deletions
+324
-271
BaseToastMessages.tsx
src/shared/components/BaseToastMessages.tsx
+153
-0
BaseToastMessages-test.js
src/shared/components/test/BaseToastMessages-test.js
+153
-0
ToastMessages.tsx
src/sidebar/components/ToastMessages.tsx
+6
-143
ToastMessages-test.js
src/sidebar/components/test/ToastMessages-test.js
+12
-128
No files found.
src/shared/components/BaseToastMessages.tsx
0 → 100644
View file @
c24514cb
import
{
Card
,
Link
,
CancelIcon
,
CautionIcon
,
CheckIcon
,
}
from
'@hypothesis/frontend-shared/lib/next'
;
import
classnames
from
'classnames'
;
import
type
{
ToastMessage
}
from
'../../sidebar/store/modules/toast-messages'
;
type
ToastMessageItemProps
=
{
message
:
ToastMessage
;
onDismiss
:
(
id
:
string
)
=>
void
;
};
/**
* An individual toast message: a brief and transient success or error message.
* The message may be dismissed by clicking on it. `visuallyHidden` toast
* messages will not be visible but are still available to screen readers.
*
* Otherwise, the `toastMessenger` service handles removing messages after a
* certain amount of time.
*/
function
ToastMessageItem
({
message
,
onDismiss
}:
ToastMessageItemProps
)
{
// Capitalize the message type for prepending; Don't prepend a message
// type for "notice" messages
const
prefix
=
message
.
type
!==
'notice'
?
`
${
message
.
type
.
charAt
(
0
).
toUpperCase
()
+
message
.
type
.
slice
(
1
)}
: `
:
''
;
let
Icon
;
switch
(
message
.
type
)
{
case
'success'
:
Icon
=
CheckIcon
;
break
;
case
'error'
:
Icon
=
CancelIcon
;
break
;
case
'notice'
:
default
:
Icon
=
CautionIcon
;
break
;
}
/**
* 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 */
<
Card
classes=
{
classnames
(
'flex'
,
{
'sr-only'
:
message
.
visuallyHidden
,
'border-red-error'
:
message
.
type
===
'error'
,
'border-yellow-notice'
:
message
.
type
===
'notice'
,
'border-green-success'
:
message
.
type
===
'success'
,
})
}
onClick=
{
()
=>
onDismiss
(
message
.
id
)
}
>
<
div
className=
{
classnames
(
'flex items-center p-3 text-white'
,
{
'bg-red-error'
:
message
.
type
===
'error'
,
'bg-yellow-notice'
:
message
.
type
===
'notice'
,
'bg-green-success'
:
message
.
type
===
'success'
,
})
}
>
<
Icon
className=
{
classnames
(
// Adjust alignment of icon to appear more aligned with text
'mt-[2px]'
)
}
/>
</
div
>
<
div
className=
"grow p-3"
data
-
testid=
"toast-message-text"
>
<
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"
>
More info
</
Link
>
</
div
>
)
}
</
div
>
</
Card
>
);
}
export
type
ToastMessageProps
=
{
messages
:
ToastMessage
[];
onMessageDismiss
:
(
messageId
:
string
)
=>
void
;
};
/**
* A collection of toast messages. These are rendered within an `aria-live`
* region for accessibility with screen readers.
*/
export
default
function
BaseToastMessages
({
messages
,
onMessageDismiss
,
}:
ToastMessageProps
)
{
// 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.
return
(
<
div
>
<
ul
aria
-
live=
"polite"
aria
-
relevant=
"additions"
className=
"absolute z-2 left-0 w-full"
>
{
messages
.
map
(
message
=>
(
<
li
className=
{
classnames
(
'relative w-full container hover:cursor-pointer'
,
{
// 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
// the first visible message in a list that contains (only)
// 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
}
/>
</
li
>
))
}
</
ul
>
</
div
>
);
}
src/shared/components/test/BaseToastMessages-test.js
0 → 100644
View file @
c24514cb
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/sidebar/components/ToastMessages.tsx
View file @
c24514cb
import
{
import
BaseToastMessages
from
'../../shared/components/BaseToastMessages'
;
Card
,
Link
,
CancelIcon
,
CautionIcon
,
CheckIcon
,
}
from
'@hypothesis/frontend-shared/lib/next'
;
import
classnames
from
'classnames'
;
import
{
withServices
}
from
'../service-context'
;
import
{
withServices
}
from
'../service-context'
;
import
type
{
ToastMessengerService
}
from
'../services/toast-messenger'
;
import
type
{
ToastMessengerService
}
from
'../services/toast-messenger'
;
import
{
useSidebarStore
}
from
'../store'
;
import
{
useSidebarStore
}
from
'../store'
;
import
type
{
ToastMessage
}
from
'../store/modules/toast-messages'
;
type
ToastMessageItemProps
=
{
message
:
ToastMessage
;
onDismiss
:
(
id
:
string
)
=>
void
;
};
/**
* An individual toast message: a brief and transient success or error message.
* The message may be dismissed by clicking on it. `visuallyHidden` toast
* messages will not be visible but are still available to screen readers.
*
* Otherwise, the `toastMessenger` service handles removing messages after a
* certain amount of time.
*/
function
ToastMessageItem
({
message
,
onDismiss
}:
ToastMessageItemProps
)
{
// Capitalize the message type for prepending; Don't prepend a message
// type for "notice" messages
const
prefix
=
message
.
type
!==
'notice'
?
`
${
message
.
type
.
charAt
(
0
).
toUpperCase
()
+
message
.
type
.
slice
(
1
)}
: `
:
''
;
let
Icon
;
switch
(
message
.
type
)
{
case
'success'
:
Icon
=
CheckIcon
;
break
;
case
'error'
:
Icon
=
CancelIcon
;
break
;
case
'notice'
:
default
:
Icon
=
CautionIcon
;
break
;
}
/**
* 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 */
<
Card
classes=
{
classnames
(
'flex'
,
{
'sr-only'
:
message
.
visuallyHidden
,
'border-red-error'
:
message
.
type
===
'error'
,
'border-yellow-notice'
:
message
.
type
===
'notice'
,
'border-green-success'
:
message
.
type
===
'success'
,
})
}
onClick=
{
()
=>
onDismiss
(
message
.
id
)
}
>
<
div
className=
{
classnames
(
'flex items-center p-3 text-white'
,
{
'bg-red-error'
:
message
.
type
===
'error'
,
'bg-yellow-notice'
:
message
.
type
===
'notice'
,
'bg-green-success'
:
message
.
type
===
'success'
,
})
}
>
<
Icon
className=
{
classnames
(
// Adjust alignment of icon to appear more aligned with text
'mt-[2px]'
)
}
/>
</
div
>
<
div
className=
"grow p-3"
data
-
testid=
"toast-message-text"
>
<
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"
>
More info
</
Link
>
</
div
>
)
}
</
div
>
</
Card
>
);
}
export
type
ToastMessageProps
=
{
export
type
ToastMessageProps
=
{
// injected
// injected
toastMessenger
:
ToastMessengerService
;
toastMessenger
:
ToastMessengerService
;
};
};
/**
* A collection of toast messages. These are rendered within an `aria-live`
* region for accessibility with screen readers.
*/
function
ToastMessages
({
toastMessenger
}:
ToastMessageProps
)
{
function
ToastMessages
({
toastMessenger
}:
ToastMessageProps
)
{
const
store
=
useSidebarStore
();
const
store
=
useSidebarStore
();
const
messages
=
store
.
getToastMessages
();
const
messages
=
store
.
getToastMessages
();
// 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.
return
(
return
(
<
div
>
<
BaseToastMessages
<
ul
messages=
{
messages
}
aria
-
live=
"polite"
onMessageDismiss=
{
(
id
:
string
)
=>
toastMessenger
.
dismiss
(
id
)
}
aria
-
relevant=
"additions"
/>
className=
"absolute z-2 left-0 w-full"
>
{
messages
.
map
(
message
=>
(
<
li
className=
{
classnames
(
'relative w-full container hover:cursor-pointer'
,
{
// 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
// the first visible message in a list that contains (only)
// 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 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=
{
(
id
:
string
)
=>
toastMessenger
.
dismiss
(
id
)
}
/>
</
li
>
))
}
</
ul
>
</
div
>
);
);
}
}
...
...
src/sidebar/components/test/ToastMessages-test.js
View file @
c24514cb
import
{
mount
}
from
'enzyme'
;
import
{
mount
}
from
'enzyme'
;
import
{
act
}
from
'preact/test-utils'
;
import
{
checkAccessibility
}
from
'../../../test-util/accessibility'
;
import
{
mockImportedComponents
}
from
'../../../test-util/mock-imported-components'
;
import
{
mockImportedComponents
}
from
'../../../test-util/mock-imported-components'
;
import
ToastMessages
,
{
$imports
}
from
'../ToastMessages'
;
import
ToastMessages
,
{
$imports
}
from
'../ToastMessages'
;
...
@@ -9,29 +7,11 @@ describe('ToastMessages', () => {
...
@@ -9,29 +7,11 @@ describe('ToastMessages', () => {
let
fakeStore
;
let
fakeStore
;
let
fakeToastMessenger
;
let
fakeToastMessenger
;
let
fakeErrorMessage
=
()
=>
{
const
fakeMessage
=
()
=>
{
return
{
type
:
'error'
,
message
:
'boo'
,
id
:
'someid2'
,
isDismissed
:
false
,
};
};
let
fakeSuccessMessage
=
()
=>
{
return
{
type
:
'success'
,
message
:
'yay'
,
id
:
'someid'
,
isDismissed
:
false
,
};
};
let
fakeNoticeMessage
=
()
=>
{
return
{
return
{
type
:
'notice'
,
type
:
'notice'
,
message
:
'you should know...'
,
message
:
'you should know...'
,
id
:
'some
id3
'
,
id
:
'some
Id
'
,
isDismissed
:
false
,
isDismissed
:
false
,
moreInfoURL
:
'http://www.example.com'
,
moreInfoURL
:
'http://www.example.com'
,
};
};
...
@@ -62,122 +42,26 @@ describe('ToastMessages', () => {
...
@@ -62,122 +42,26 @@ describe('ToastMessages', () => {
$imports
.
$restore
();
$imports
.
$restore
();
});
});
it
(
'should render a
`ToastMessageItem` for each message
returned by the store'
,
()
=>
{
it
(
'should render a
ll messages
returned by the store'
,
()
=>
{
fakeStore
.
getToastMessages
.
returns
([
fakeStore
.
getToastMessages
.
returns
([
fake
Success
Message
(),
fakeMessage
(),
fake
Error
Message
(),
fakeMessage
(),
fake
Notice
Message
(),
fakeMessage
(),
]);
]);
const
wrapper
=
createComponent
();
const
wrapper
=
createComponent
();
assert
.
lengthOf
(
wrapper
.
find
(
'
ToastMessageItem
'
),
3
);
assert
.
lengthOf
(
wrapper
.
find
(
'
BaseToastMessages'
).
prop
(
'messages
'
),
3
);
});
});
describe
(
'`ToastMessageItem` sub-component'
,
()
=>
{
it
(
'should dismiss the message when clicked'
,
()
=>
{
it
(
'should dismiss the message when clicked'
,
()
=>
{
fakeStore
.
getToastMessages
.
returns
([
fakeMessage
()]);
fakeStore
.
getToastMessages
.
returns
([
fakeSuccessMessage
()]);
const
wrapper
=
createComponent
();
const
messageContainer
=
wrapper
.
find
(
'ToastMessageItem'
).
getDOMNode
();
act
(()
=>
{
messageContainer
.
dispatchEvent
(
new
Event
(
'click'
));
});
assert
.
calledOnce
(
fakeToastMessenger
.
dismiss
);
});
it
(
'should set a screen-reader-only class on `visuallyHidden` messages'
,
()
=>
{
const
message
=
fakeSuccessMessage
();
message
.
visuallyHidden
=
true
;
fakeStore
.
getToastMessages
.
returns
([
message
]);
const
wrapper
=
createComponent
();
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'
,
()
=>
{
fakeStore
.
getToastMessages
.
returns
([
fakeNoticeMessage
()]);
const
wrapper
=
createComponent
();
const
link
=
wrapper
.
find
(
'Link'
);
act
(()
=>
{
link
.
getDOMNode
().
dispatchEvent
(
new
Event
(
'click'
,
{
bubbles
:
true
}));
});
assert
.
notCalled
(
fakeToastMessenger
.
dismiss
);
});
[
{
message
:
fakeSuccessMessage
(),
prefix
:
'Success: '
},
{
message
:
fakeErrorMessage
(),
prefix
:
'Error: '
},
{
message
:
fakeNoticeMessage
(),
prefix
:
''
},
].
forEach
(
testCase
=>
{
it
(
'should prefix the message with the message type'
,
()
=>
{
fakeStore
.
getToastMessages
.
returns
([
testCase
.
message
]);
const
wrapper
=
createComponent
();
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'
,
()
=>
{
fakeStore
.
getToastMessages
.
returns
(
testCase
.
messages
);
const
wrapper
=
createComponent
();
testCase
.
icons
.
forEach
(
iconName
=>
{
assert
.
isTrue
(
wrapper
.
find
(
iconName
).
exists
());
});
});
});
});
it
(
'should render a "more info" link if URL is present in message object'
,
()
=>
{
fakeStore
.
getToastMessages
.
returns
([
fakeNoticeMessage
()]);
const
wrapper
=
createComponent
();
const
wrapper
=
createComponent
();
const
messageContainer
=
wrapper
.
find
(
'BaseToastMessages'
);
const
link
=
wrapper
.
find
(
'Link'
);
messageContainer
.
prop
(
'onMessageDismiss'
)();
assert
.
equal
(
link
.
props
().
href
,
'http://www.example.com'
);
assert
.
equal
(
link
.
text
(),
'More info'
);
});
describe
(
'a11y'
,
()
=>
{
beforeEach
(()
=>
{
fakeStore
.
getToastMessages
.
returns
([
fakeSuccessMessage
(),
fakeErrorMessage
(),
fakeNoticeMessage
(),
]);
});
it
(
assert
.
calledOnce
(
fakeToastMessenger
.
dismiss
);
'should pass a11y checks'
,
checkAccessibility
([
{
content
:
()
=>
createComponent
(),
},
])
);
});
});
});
});
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