cozy-bar 1.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +7 -0
- package/.github/auto-merge.yml +7 -0
- package/.nvmrc +1 -0
- package/.transifexrc.tpl +4 -0
- package/.travis.yml +28 -0
- package/.tx/config +8 -0
- package/CHANGELOG.md +493 -0
- package/CODEOWNERS +2 -0
- package/CONTRIBUTING.md +135 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/babel.config.js +3 -0
- package/config/aliases/globalReact.js +1 -0
- package/config/aliases/globalReactDOM.js +1 -0
- package/config/webpack.config.analyzer.js +10 -0
- package/config/webpack.config.base.js +61 -0
- package/config/webpack.config.dev.js +16 -0
- package/config/webpack.config.extract.js +84 -0
- package/config/webpack.config.inline-styles.js +82 -0
- package/config/webpack.config.jsx.js +14 -0
- package/config/webpack.config.prod.js +18 -0
- package/config/webpack.js +31 -0
- package/config/webpack.vars.js +11 -0
- package/dist/cozy-bar.css +9445 -0
- package/dist/cozy-bar.css.map +1 -0
- package/dist/cozy-bar.js +122668 -0
- package/dist/cozy-bar.js.map +1 -0
- package/dist/cozy-bar.min.css +9 -0
- package/dist/cozy-bar.min.css.map +1 -0
- package/dist/cozy-bar.min.js +57 -0
- package/dist/cozy-bar.min.js.map +1 -0
- package/dist/cozy-bar.mobile.js +123719 -0
- package/dist/cozy-bar.mobile.js.map +1 -0
- package/dist/cozy-bar.mobile.min.js +57 -0
- package/dist/cozy-bar.mobile.min.js.map +1 -0
- package/docs/dev.md +20 -0
- package/examples/Procfile +3 -0
- package/examples/icon.png +0 -0
- package/examples/index.html +7 -0
- package/examples/index.jsx +122 -0
- package/examples/logs.js +3 -0
- package/package.json +163 -0
- package/postcss.config.js +23 -0
- package/public/fonts/Lato-Bold.woff2 +0 -0
- package/public/fonts/Lato-Regular.woff2 +0 -0
- package/public/fonts.css +13 -0
- package/public/icon-type-folder-32.png +0 -0
- package/public/index.html +63 -0
- package/renovate.json +5 -0
- package/src/assets/icons/16/icon-claudy.svg +5 -0
- package/src/assets/icons/16/icon-cozy-16.svg +1 -0
- package/src/assets/icons/16/icon-cube-16.svg +6 -0
- package/src/assets/icons/16/icon-logout-16.svg +3 -0
- package/src/assets/icons/16/icon-magnifier-16.svg +6 -0
- package/src/assets/icons/16/icon-people-16.svg +3 -0
- package/src/assets/icons/16/icon-phone-16.svg +3 -0
- package/src/assets/icons/16/icon-question-mark-16.svg +3 -0
- package/src/assets/icons/16/icon-storage-16.svg +3 -0
- package/src/assets/icons/24/icon-arrow-left.svg +3 -0
- package/src/assets/icons/32/icon-claudy.svg +1 -0
- package/src/assets/icons/apps/icon-collect.svg +25 -0
- package/src/assets/icons/apps/icon-drive.svg +17 -0
- package/src/assets/icons/apps/icon-market-soon.svg +25 -0
- package/src/assets/icons/apps/icon-photos.svg +19 -0
- package/src/assets/icons/apps/icon-soon.svg +21 -0
- package/src/assets/icons/apps/icon-store.svg +19 -0
- package/src/assets/icons/claudyActions/icon-bills.svg +6 -0
- package/src/assets/icons/claudyActions/icon-laptop.svg +7 -0
- package/src/assets/icons/claudyActions/icon-phone.svg +8 -0
- package/src/assets/icons/claudyActions/icon-question-mark.svg +6 -0
- package/src/assets/icons/comingsoon/icon-bank.svg +12 -0
- package/src/assets/icons/comingsoon/icon-sante.svg +12 -0
- package/src/assets/icons/comingsoon/icon-store.svg +6 -0
- package/src/assets/icons/icon-cozy.svg +3 -0
- package/src/assets/icons/icon-shield.svg +3 -0
- package/src/assets/icons/spinner.svg +4 -0
- package/src/assets/sprites/icon-apps.svg +1 -0
- package/src/assets/sprites/icon-cozy-home.svg +16 -0
- package/src/components/Apps/AppItem.jsx +117 -0
- package/src/components/Apps/AppItemPlaceholder.jsx +12 -0
- package/src/components/Apps/AppNavButtons.jsx +88 -0
- package/src/components/Apps/AppsContent.jsx +89 -0
- package/src/components/Apps/ButtonCozyHome.jsx +30 -0
- package/src/components/Apps/ButtonCozyHome.spec.jsx +53 -0
- package/src/components/Apps/IconCozyHome.jsx +24 -0
- package/src/components/Apps/index.jsx +81 -0
- package/src/components/Banner.jsx +41 -0
- package/src/components/Bar.jsx +293 -0
- package/src/components/Bar.spec.jsx +133 -0
- package/src/components/Claudy.jsx +81 -0
- package/src/components/Drawer.jsx +227 -0
- package/src/components/Drawer.spec.jsx +98 -0
- package/src/components/SearchBar.jsx +358 -0
- package/src/components/Settings/SettingsContent.jsx +145 -0
- package/src/components/Settings/StorageData.jsx +29 -0
- package/src/components/Settings/helper.js +8 -0
- package/src/components/Settings/index.jsx +218 -0
- package/src/components/SupportModal.jsx +59 -0
- package/src/components/__snapshots__/Bar.spec.jsx.snap +302 -0
- package/src/config/claudyActions.yaml +14 -0
- package/src/config/persistWhitelist.yaml +2 -0
- package/src/dom.js +80 -0
- package/src/index.jsx +235 -0
- package/src/index.spec.jsx +34 -0
- package/src/lib/api/helpers.js +13 -0
- package/src/lib/api/index.jsx +145 -0
- package/src/lib/exceptions.js +89 -0
- package/src/lib/expiringMemoize.js +13 -0
- package/src/lib/icon.js +77 -0
- package/src/lib/importIcons.js +14 -0
- package/src/lib/intents.js +16 -0
- package/src/lib/logger.js +6 -0
- package/src/lib/middlewares/appsI18n.js +57 -0
- package/src/lib/realtime.js +43 -0
- package/src/lib/reducers/apps.js +175 -0
- package/src/lib/reducers/apps.spec.js +59 -0
- package/src/lib/reducers/content.js +50 -0
- package/src/lib/reducers/context.js +83 -0
- package/src/lib/reducers/index.js +73 -0
- package/src/lib/reducers/locale.js +22 -0
- package/src/lib/reducers/settings.js +111 -0
- package/src/lib/reducers/theme.js +48 -0
- package/src/lib/reducers/unserializable.js +26 -0
- package/src/lib/stack-client.js +401 -0
- package/src/lib/stack.js +79 -0
- package/src/lib/store/index.js +54 -0
- package/src/locales/de.json +57 -0
- package/src/locales/en.json +57 -0
- package/src/locales/es.json +57 -0
- package/src/locales/fr.json +57 -0
- package/src/locales/it.json +57 -0
- package/src/locales/ja.json +57 -0
- package/src/locales/nl_NL.json +57 -0
- package/src/locales/pl.json +57 -0
- package/src/locales/ru.json +57 -0
- package/src/locales/sq.json +57 -0
- package/src/locales/zh_CN.json +57 -0
- package/src/proptypes/index.js +10 -0
- package/src/queries/index.js +16 -0
- package/src/styles/apps.css +248 -0
- package/src/styles/banner.css +64 -0
- package/src/styles/bar.css +106 -0
- package/src/styles/base.css +41 -0
- package/src/styles/claudy.css +99 -0
- package/src/styles/drawer.css +126 -0
- package/src/styles/index.styl +33 -0
- package/src/styles/indicators.css +58 -0
- package/src/styles/nav.css +81 -0
- package/src/styles/navigation_item.css +39 -0
- package/src/styles/searchbar.css +158 -0
- package/src/styles/settings.css +44 -0
- package/src/styles/storage.css +22 -0
- package/src/styles/supportModal.css +20 -0
- package/src/styles/theme.styl +25 -0
- package/test/__mocks__/fileMock.js +3 -0
- package/test/__snapshots__/index.spec.js.snap +41 -0
- package/test/components/AppItem.spec.jsx +113 -0
- package/test/components/AppsContent.spec.jsx +116 -0
- package/test/components/Settings/helper.spec.js +23 -0
- package/test/components/__snapshots__/AppsContent.spec.jsx.snap +90 -0
- package/test/index.spec.js +24 -0
- package/test/jestLib/I18n.js +15 -0
- package/test/jestLib/setup.js +14 -0
- package/test/lib/__snapshots__/api.spec.jsx.snap +67 -0
- package/test/lib/__snapshots__/stack.spec.js.snap +3 -0
- package/test/lib/api.spec.jsx +142 -0
- package/test/lib/mockStackClient.js +7 -0
- package/test/lib/stack-client/stack-client.appiconprops.spec.js +65 -0
- package/test/lib/stack-client/stack-client.compare.spec.js +20 -0
- package/test/lib/stack-client/stack-client.cozyfetchjson.spec.js +46 -0
- package/test/lib/stack-client/stack-client.cozyurl.spec.js +29 -0
- package/test/lib/stack-client/stack-client.getapp.spec.js +72 -0
- package/test/lib/stack-client/stack-client.getapps.spec.js +72 -0
- package/test/lib/stack-client/stack-client.getcontext.spec.js +96 -0
- package/test/lib/stack-client/stack-client.getstoragedata.spec.js +85 -0
- package/test/lib/stack-client/stack-client.init.spec.js +56 -0
- package/test/lib/stack-client/stack-client.intents.spec.js +27 -0
- package/test/lib/stack-client/stack-client.logout.spec.js +40 -0
- package/test/lib/stack.spec.js +60 -0
- package/test/store/__snapshots__/index.spec.js.snap +14 -0
- package/test/store/index.spec.js +41 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import React, { Component } from 'react'
|
|
2
|
+
import { connect } from 'react-redux'
|
|
3
|
+
import Hammer from 'hammerjs'
|
|
4
|
+
|
|
5
|
+
import AppsContent from 'components/Apps/AppsContent'
|
|
6
|
+
import SettingsContent from 'components/Settings/SettingsContent'
|
|
7
|
+
import {
|
|
8
|
+
fetchSettingsData,
|
|
9
|
+
getSettingsAppURL,
|
|
10
|
+
getStorageData,
|
|
11
|
+
logOut
|
|
12
|
+
} from 'lib/reducers'
|
|
13
|
+
|
|
14
|
+
class Drawer extends Component {
|
|
15
|
+
constructor(props) {
|
|
16
|
+
super(props)
|
|
17
|
+
this.state = {
|
|
18
|
+
isScrolling: false,
|
|
19
|
+
isClosing: false
|
|
20
|
+
}
|
|
21
|
+
this.handleLogout = this.handleLogout.bind(this)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
onDrawerClick = event => {
|
|
25
|
+
if (event.target === this.wrapperRef) {
|
|
26
|
+
this.close()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
onTransitionEnd = () => {
|
|
31
|
+
if (this.props.visible) {
|
|
32
|
+
if (!this.gesturesHandler) this.attachGestures()
|
|
33
|
+
this.preventBackgroundScrolling()
|
|
34
|
+
} else {
|
|
35
|
+
this.restoreBackgroundScrolling()
|
|
36
|
+
this.setState({ isClosing: false })
|
|
37
|
+
}
|
|
38
|
+
this.props.drawerListener()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async componentDidMount() {
|
|
42
|
+
this.turnTransitionsOn()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
componentWillReceiveProps = async nextProps => {
|
|
46
|
+
await this.UNSAFE_componentWillReceiveProps(nextProps)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
UNSAFE_componentWillReceiveProps = async nextProps => {
|
|
50
|
+
if (!this.props.visible && nextProps.visible) {
|
|
51
|
+
await this.props.fetchSettingsData()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
turnTransitionsOn() {
|
|
56
|
+
this.asideRef.classList.add('with-transition')
|
|
57
|
+
this.asideRef.addEventListener('transitionend', this.onTransitionEnd)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
turnTransitionsOff() {
|
|
61
|
+
this.asideRef.classList.remove('with-transition')
|
|
62
|
+
this.asideRef.removeEventListener('transitionend', this.onTransitionEnd)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
preventBackgroundScrolling() {
|
|
66
|
+
document.body.style.overflow = 'hidden'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
restoreBackgroundScrolling() {
|
|
70
|
+
document.body.style.overflow = 'auto'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
detachGestures() {
|
|
74
|
+
this.gesturesHandler.destroy()
|
|
75
|
+
this.gesturesHandler = null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
attachGestures() {
|
|
79
|
+
// IMPORTANT: on Chrome, the `overflow-y: scroll` property on .coz-drawer--apps prevented
|
|
80
|
+
// swipe events to be dispatched correctly ; the `touch-action: pan-y` fixes the problem
|
|
81
|
+
// see drawer.css
|
|
82
|
+
this.gesturesHandler = new Hammer.Manager(document.documentElement, {
|
|
83
|
+
// we listen in all directions so that we can catch panup/pandown events and let the user scroll
|
|
84
|
+
recognizers: [[Hammer.Pan, { direction: Hammer.DIRECTION_ALL }]]
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// to be completely accurate, `maximumGestureDelta` should be the difference between the right of the aside and the
|
|
88
|
+
// left of the page; but using the width is much easier to compute and accurate enough.
|
|
89
|
+
const maximumGestureDistance = this.asideRef.getBoundingClientRect().width
|
|
90
|
+
// between 0 and 1, how far down the gesture must be to be considered complete upon release
|
|
91
|
+
const minimumCloseDistance = 0.4
|
|
92
|
+
// a gesture faster than this will dismiss the menu, regardless of distance traveled
|
|
93
|
+
const minimumCloseVelocity = 0.2
|
|
94
|
+
|
|
95
|
+
let currentGestureProgress = null
|
|
96
|
+
|
|
97
|
+
this.gesturesHandler.on('panstart', event => {
|
|
98
|
+
if (this.state.isClosing) return
|
|
99
|
+
if (
|
|
100
|
+
event.additionalEvent === 'panup' ||
|
|
101
|
+
event.additionalEvent === 'pandown'
|
|
102
|
+
) {
|
|
103
|
+
this.setState({ isScrolling: true })
|
|
104
|
+
} else {
|
|
105
|
+
this.turnTransitionsOff()
|
|
106
|
+
currentGestureProgress = 0
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
this.gesturesHandler.on('pan', e => {
|
|
111
|
+
if (this.state.isClosing || this.state.isScrolling) return
|
|
112
|
+
currentGestureProgress = -e.deltaX / maximumGestureDistance
|
|
113
|
+
this.applyTransformation(currentGestureProgress)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
this.gesturesHandler.on('panend', e => {
|
|
117
|
+
if (this.state.isClosing) return
|
|
118
|
+
if (this.state.isScrolling) {
|
|
119
|
+
this.setState({ isScrolling: false })
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
// Dismiss the menu if the swipe pan was bigger than the treshold,
|
|
123
|
+
// or if it was a fast, leftward gesture
|
|
124
|
+
const haveTravelledFarEnough =
|
|
125
|
+
-e.deltaX / maximumGestureDistance >= minimumCloseDistance
|
|
126
|
+
const haveTravelledFastEnough =
|
|
127
|
+
e.velocity < 0 && Math.abs(e.velocity) >= minimumCloseVelocity
|
|
128
|
+
|
|
129
|
+
const shouldDismiss = haveTravelledFarEnough || haveTravelledFastEnough
|
|
130
|
+
|
|
131
|
+
if (shouldDismiss) {
|
|
132
|
+
this.close()
|
|
133
|
+
} else {
|
|
134
|
+
this.turnTransitionsOn()
|
|
135
|
+
this.applyTransformation(0)
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
close = () => {
|
|
141
|
+
if (this.state.isClosing) return
|
|
142
|
+
this.detachGestures()
|
|
143
|
+
this.setState(() => ({ isClosing: true }))
|
|
144
|
+
this.turnTransitionsOn()
|
|
145
|
+
this.props.onClose()
|
|
146
|
+
this.asideRef.style.transform = ''
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
applyTransformation(progress) {
|
|
150
|
+
// constrain between 0 and 1.1 (go a bit further than 1 to be hidden completely)
|
|
151
|
+
progress = Math.min(1.1, Math.max(0, progress))
|
|
152
|
+
this.asideRef.style.transform = 'translateX(-' + progress * 100 + '%)'
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async handleLogout() {
|
|
156
|
+
const { onLogOut, logOut } = this.props
|
|
157
|
+
|
|
158
|
+
if (onLogOut && typeof onLogOut === 'function') {
|
|
159
|
+
const res = onLogOut()
|
|
160
|
+
if (res instanceof Promise) {
|
|
161
|
+
await res
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
logOut()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
render() {
|
|
169
|
+
const {
|
|
170
|
+
onClaudy,
|
|
171
|
+
visible,
|
|
172
|
+
isClaudyLoading,
|
|
173
|
+
toggleSupport,
|
|
174
|
+
settingsAppURL,
|
|
175
|
+
storageData
|
|
176
|
+
} = this.props
|
|
177
|
+
return (
|
|
178
|
+
<div
|
|
179
|
+
className="coz-drawer-wrapper"
|
|
180
|
+
onClick={this.onDrawerClick}
|
|
181
|
+
aria-hidden={visible ? 'false' : 'true'}
|
|
182
|
+
ref={node => {
|
|
183
|
+
this.wrapperRef = node
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
<aside
|
|
187
|
+
ref={node => {
|
|
188
|
+
this.asideRef = node
|
|
189
|
+
}}
|
|
190
|
+
>
|
|
191
|
+
<nav className="coz-drawer--apps">
|
|
192
|
+
<AppsContent onAppSwitch={this.close} />
|
|
193
|
+
</nav>
|
|
194
|
+
<hr className="coz-sep-flex" />
|
|
195
|
+
<nav className="coz-drawer--settings">
|
|
196
|
+
<SettingsContent
|
|
197
|
+
onLogOut={this.handleLogout}
|
|
198
|
+
storageData={storageData}
|
|
199
|
+
settingsAppURL={settingsAppURL}
|
|
200
|
+
isClaudyLoading={isClaudyLoading}
|
|
201
|
+
onClaudy={onClaudy}
|
|
202
|
+
toggleSupport={toggleSupport}
|
|
203
|
+
isDrawer
|
|
204
|
+
/>
|
|
205
|
+
</nav>
|
|
206
|
+
</aside>
|
|
207
|
+
</div>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export { Drawer }
|
|
213
|
+
|
|
214
|
+
const mapStateToProps = state => ({
|
|
215
|
+
storageData: getStorageData(state),
|
|
216
|
+
settingsAppURL: getSettingsAppURL(state)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const mapDispatchToProps = dispatch => ({
|
|
220
|
+
fetchSettingsData: () => dispatch(fetchSettingsData()),
|
|
221
|
+
logOut: () => dispatch(logOut())
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
export default connect(
|
|
225
|
+
mapStateToProps,
|
|
226
|
+
mapDispatchToProps
|
|
227
|
+
)(Drawer)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Provider } from 'react-redux'
|
|
3
|
+
|
|
4
|
+
import I18n from 'cozy-ui/react/I18n'
|
|
5
|
+
import { createStore } from 'lib/store'
|
|
6
|
+
import enLocale from 'locales/en.json'
|
|
7
|
+
import { render, screen, fireEvent, act } from '@testing-library/react'
|
|
8
|
+
import { Drawer } from './Drawer'
|
|
9
|
+
|
|
10
|
+
const sleep = duration => new Promise(resolve => setTimeout(resolve, duration))
|
|
11
|
+
|
|
12
|
+
const fakeStore = createStore()
|
|
13
|
+
|
|
14
|
+
const Wrapper = ({ children }) => {
|
|
15
|
+
return (
|
|
16
|
+
<Provider store={fakeStore}>
|
|
17
|
+
<I18n dictRequire={() => enLocale} lang="en">
|
|
18
|
+
{children}
|
|
19
|
+
</I18n>
|
|
20
|
+
</Provider>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('bar', () => {
|
|
25
|
+
describe('logout', () => {
|
|
26
|
+
const findLogoutButton = () => {
|
|
27
|
+
return screen.getByText('Sign out')
|
|
28
|
+
}
|
|
29
|
+
const setup = ({ onLogOut, logOut }) => {
|
|
30
|
+
render(
|
|
31
|
+
<Wrapper>
|
|
32
|
+
<Drawer
|
|
33
|
+
toggleSupport={jest.fn()}
|
|
34
|
+
logOut={logOut}
|
|
35
|
+
onLogOut={onLogOut}
|
|
36
|
+
/>
|
|
37
|
+
</Wrapper>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const clickLogout = () => {
|
|
42
|
+
const logoutButton = findLogoutButton()
|
|
43
|
+
fireEvent(
|
|
44
|
+
logoutButton,
|
|
45
|
+
new MouseEvent('click', {
|
|
46
|
+
bubbles: true,
|
|
47
|
+
cancelable: true
|
|
48
|
+
})
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
it('should await the onLogOut', async () => {
|
|
53
|
+
let prom
|
|
54
|
+
const callOrder = []
|
|
55
|
+
const logOut = jest.fn().mockImplementation(() => {
|
|
56
|
+
callOrder.push('logOut')
|
|
57
|
+
})
|
|
58
|
+
const onLogOut = jest.fn().mockImplementation(async () => {
|
|
59
|
+
prom = sleep(100)
|
|
60
|
+
callOrder.push('onLogOut')
|
|
61
|
+
await prom
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
setup({ logOut, onLogOut })
|
|
65
|
+
|
|
66
|
+
act(() => {
|
|
67
|
+
clickLogout()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
expect(logOut).not.toHaveBeenCalled()
|
|
71
|
+
await prom
|
|
72
|
+
await sleep(0)
|
|
73
|
+
expect(logOut).toHaveBeenCalled()
|
|
74
|
+
expect(onLogOut).toHaveBeenCalled()
|
|
75
|
+
expect(callOrder).toEqual(['onLogOut', 'logOut'])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('should work if onLogOut has not been passed', () => {
|
|
79
|
+
const logOut = jest.fn()
|
|
80
|
+
setup({ logOut })
|
|
81
|
+
act(() => {
|
|
82
|
+
clickLogout()
|
|
83
|
+
})
|
|
84
|
+
expect(logOut).toHaveBeenCalled()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('should work if onLogOut does not return a promise', () => {
|
|
88
|
+
const logOut = jest.fn()
|
|
89
|
+
const onLogOut = jest.fn()
|
|
90
|
+
setup({ logOut, onLogOut })
|
|
91
|
+
act(() => {
|
|
92
|
+
clickLogout()
|
|
93
|
+
})
|
|
94
|
+
expect(logOut).toHaveBeenCalled()
|
|
95
|
+
expect(onLogOut).toHaveBeenCalled()
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import React, { Component } from 'react'
|
|
2
|
+
import { translate } from 'cozy-ui/react/I18n'
|
|
3
|
+
import Autosuggest from 'react-autosuggest'
|
|
4
|
+
import debounce from 'lodash.debounce'
|
|
5
|
+
import { fetchRawIntent } from 'lib/intents'
|
|
6
|
+
import logger from 'lib/logger'
|
|
7
|
+
|
|
8
|
+
const INTENT_VERB = 'OPEN'
|
|
9
|
+
const INTENT_DOCTYPE = 'io.cozy.suggestions'
|
|
10
|
+
const SUGGESTIONS_PER_SOURCE = 10
|
|
11
|
+
|
|
12
|
+
const normalizeString = str =>
|
|
13
|
+
str
|
|
14
|
+
.toString()
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/\//g, ' ')
|
|
17
|
+
.normalize('NFD')
|
|
18
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
19
|
+
.split(' ')
|
|
20
|
+
|
|
21
|
+
const highlightQueryTerms = (searchResult, query) => {
|
|
22
|
+
const normalizedQueryTerms = normalizeString(query)
|
|
23
|
+
const normalizedResultTerms = normalizeString(searchResult)
|
|
24
|
+
|
|
25
|
+
const matchedIntervals = []
|
|
26
|
+
const spacerLength = 1
|
|
27
|
+
let currentIndex = 0
|
|
28
|
+
|
|
29
|
+
normalizedResultTerms.forEach(resultTerm => {
|
|
30
|
+
normalizedQueryTerms.forEach(queryTerm => {
|
|
31
|
+
const index = resultTerm.indexOf(queryTerm)
|
|
32
|
+
if (index >= 0) {
|
|
33
|
+
matchedIntervals.push({
|
|
34
|
+
from: currentIndex + index,
|
|
35
|
+
to: currentIndex + index + queryTerm.length
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
currentIndex += resultTerm.length + spacerLength
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// matchedIntervals can overlap, so we merge them.
|
|
44
|
+
// - sort the intervals by starting index
|
|
45
|
+
// - add the first interval to the stack
|
|
46
|
+
// - for every interval,
|
|
47
|
+
// - - add it to the stack if it doesn't overlap with the stack top
|
|
48
|
+
// - - or extend the stack top if the start overlaps and the new interval's top is bigger
|
|
49
|
+
const mergedIntervals = matchedIntervals
|
|
50
|
+
.sort((intervalA, intervalB) => intervalA.from > intervalB.from)
|
|
51
|
+
.reduce((computedIntervals, newInterval) => {
|
|
52
|
+
if (
|
|
53
|
+
computedIntervals.length === 0 ||
|
|
54
|
+
computedIntervals[computedIntervals.length - 1].to < newInterval.from
|
|
55
|
+
) {
|
|
56
|
+
computedIntervals.push(newInterval)
|
|
57
|
+
} else if (
|
|
58
|
+
computedIntervals[computedIntervals.length - 1].to < newInterval.to
|
|
59
|
+
) {
|
|
60
|
+
computedIntervals[computedIntervals.length - 1].to = newInterval.to
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return computedIntervals
|
|
64
|
+
}, [])
|
|
65
|
+
|
|
66
|
+
// create an array containing the entire search result, with special characters, and the intervals surrounded y `<b>` tags
|
|
67
|
+
const slicedOriginalResult =
|
|
68
|
+
mergedIntervals.length > 0
|
|
69
|
+
? [searchResult.slice(0, mergedIntervals[0].from)]
|
|
70
|
+
: searchResult
|
|
71
|
+
|
|
72
|
+
for (let i = 0, l = mergedIntervals.length; i < l; ++i) {
|
|
73
|
+
slicedOriginalResult.push(
|
|
74
|
+
<b>
|
|
75
|
+
{searchResult.slice(mergedIntervals[i].from, mergedIntervals[i].to)}
|
|
76
|
+
</b>
|
|
77
|
+
)
|
|
78
|
+
if (i + 1 < l)
|
|
79
|
+
slicedOriginalResult.push(
|
|
80
|
+
searchResult.slice(mergedIntervals[i].to, mergedIntervals[i + 1].from)
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (mergedIntervals.length > 0)
|
|
85
|
+
slicedOriginalResult.push(
|
|
86
|
+
searchResult.slice(
|
|
87
|
+
mergedIntervals[mergedIntervals.length - 1].to,
|
|
88
|
+
searchResult.length
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return slicedOriginalResult
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
class SearchBar extends Component {
|
|
96
|
+
state = {
|
|
97
|
+
input: '',
|
|
98
|
+
query: null,
|
|
99
|
+
searching: false,
|
|
100
|
+
focused: false,
|
|
101
|
+
suggestionsBySource: [],
|
|
102
|
+
sourceURLs: []
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
sources = []
|
|
106
|
+
|
|
107
|
+
componentWillMount() {
|
|
108
|
+
this.debouncedOnSuggestionsFetchRequested = debounce(
|
|
109
|
+
this.onSuggestionsFetchRequested,
|
|
110
|
+
250
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
componentDidMount() {
|
|
115
|
+
// The searchbar has one or more sources that provide suggestions. These sources are iframes into other apps, provied by thee intent system.
|
|
116
|
+
// Since we need to call the sources whenever the query changes, we are taking manual control over the intent process.
|
|
117
|
+
fetchRawIntent(INTENT_VERB, INTENT_DOCTYPE).then(intent => {
|
|
118
|
+
const { services } = intent.attributes
|
|
119
|
+
if (!services) return null
|
|
120
|
+
|
|
121
|
+
this.sources = services.map(service => {
|
|
122
|
+
const url = service.href
|
|
123
|
+
this.setState(state => ({
|
|
124
|
+
...state,
|
|
125
|
+
sourceURLs: [...state.sourceURLs, url]
|
|
126
|
+
}))
|
|
127
|
+
const serviceOrigin = url.split('/', 3).join('/')
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
slug: service.slug, // can be used to show where a suggestion comes from
|
|
131
|
+
origin: serviceOrigin,
|
|
132
|
+
id: intent._id,
|
|
133
|
+
ready: false,
|
|
134
|
+
window: null, // will hold a reference to the window we're sending messages to
|
|
135
|
+
resolvers: {} // will hold references to a function to call when the source sends suggestions
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
window.addEventListener('message', this.onMessageFromSource(this.sources))
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
onMessageFromSource = sources => event => {
|
|
144
|
+
// this re-implements a subset of injectService found in lib/intents, though only the part that are useful for suggestions
|
|
145
|
+
const source = sources.find(source => source.origin === event.origin)
|
|
146
|
+
|
|
147
|
+
if (!source) return null
|
|
148
|
+
|
|
149
|
+
if (event.data.type === `intent-${source.id}:ready`) {
|
|
150
|
+
source.ready = true
|
|
151
|
+
source.window = event.source
|
|
152
|
+
|
|
153
|
+
source.window.postMessage({}, event.origin)
|
|
154
|
+
} else if (
|
|
155
|
+
event.data.type === `intent-${source.id}:data` &&
|
|
156
|
+
source.resolvers[event.data.id]
|
|
157
|
+
) {
|
|
158
|
+
source.resolvers[event.data.id]({
|
|
159
|
+
id: source.id,
|
|
160
|
+
suggestions: event.data.suggestions
|
|
161
|
+
})
|
|
162
|
+
delete source.resolvers[event.data.id]
|
|
163
|
+
} else {
|
|
164
|
+
logger.log('unhandled message:', event)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
onChange = (event, { newValue }) => {
|
|
169
|
+
this.setState({
|
|
170
|
+
input: newValue
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
changeFocusState = focused => {
|
|
175
|
+
this.setState({
|
|
176
|
+
focused
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
clearSuggestions = () => {
|
|
181
|
+
this.setState({
|
|
182
|
+
suggestionsBySource: []
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
onSuggestionsFetchRequested = ({ value }) => {
|
|
187
|
+
const availableSources = this.sources.filter(source => source.ready)
|
|
188
|
+
|
|
189
|
+
if (availableSources.length > 0) {
|
|
190
|
+
// We defer the emptying of `suggestionsBySource` so that we still display
|
|
191
|
+
// the previous suggestion list
|
|
192
|
+
this.setState(state => ({ ...state, searching: true }))
|
|
193
|
+
|
|
194
|
+
availableSources.forEach(async source => {
|
|
195
|
+
const { id, suggestions } = await new Promise(resolve => {
|
|
196
|
+
const resolverId = new Date().getTime().toString()
|
|
197
|
+
source.resolvers[resolverId] = resolve
|
|
198
|
+
source.window.postMessage(
|
|
199
|
+
{ query: value, id: resolverId },
|
|
200
|
+
source.origin
|
|
201
|
+
)
|
|
202
|
+
})
|
|
203
|
+
const title = this.sources.find(source => source.id === id).slug
|
|
204
|
+
// This is the first result we get for this new search term,
|
|
205
|
+
// we can now update `query` and replace the previous `suggestionsBySource`
|
|
206
|
+
if (this.state.query !== value) {
|
|
207
|
+
this.setState(state => ({
|
|
208
|
+
...state,
|
|
209
|
+
searching: false,
|
|
210
|
+
query: value,
|
|
211
|
+
suggestionsBySource: [{ title, suggestions }]
|
|
212
|
+
}))
|
|
213
|
+
} else {
|
|
214
|
+
this.setState(state => ({
|
|
215
|
+
...state,
|
|
216
|
+
suggestionsBySource: [
|
|
217
|
+
...state.suggestionsBySource,
|
|
218
|
+
{ title, suggestions }
|
|
219
|
+
]
|
|
220
|
+
}))
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
onSuggestionsClearRequested = () => {
|
|
227
|
+
this.clearSuggestions()
|
|
228
|
+
this.debouncedOnSuggestionsFetchRequested.cancel()
|
|
229
|
+
this.setState({ query: null, searching: false })
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
onSuggestionSelected = (event, { suggestion }) => {
|
|
233
|
+
const { onSelect } = suggestion
|
|
234
|
+
// `onSelect` is a string that describes what should happen when the suggestion is selected. Currently, the only format we're supporting is `open:http://example.com` to change the url of the current page.
|
|
235
|
+
|
|
236
|
+
if (/^open:/.test(onSelect)) {
|
|
237
|
+
const url = onSelect.substr(5)
|
|
238
|
+
window.location.href = url
|
|
239
|
+
} else {
|
|
240
|
+
// eslint-disable-next-line no-console
|
|
241
|
+
console.log(
|
|
242
|
+
'suggestion onSelect (' + onSelect + ') could not be executed'
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
this.setState({ input: '', query: null })
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
getSectionSuggestions = section =>
|
|
250
|
+
section.suggestions.slice(0, SUGGESTIONS_PER_SOURCE)
|
|
251
|
+
|
|
252
|
+
// We want the user to find folders in which he can then navigate into, so we return the path here
|
|
253
|
+
getSuggestionValue = suggestion => suggestion.subtitle
|
|
254
|
+
|
|
255
|
+
renderSectionTitle = () => null // we only have one section at the moment, but if we decide to sort suggestions by section/source, we can use this callback
|
|
256
|
+
|
|
257
|
+
renderSuggestion = suggestion => (
|
|
258
|
+
<div className="coz-searchbar-autosuggest-suggestion-item">
|
|
259
|
+
{suggestion.icon && (
|
|
260
|
+
<img
|
|
261
|
+
className="coz-searchbar-autosuggest-suggestion-icon"
|
|
262
|
+
src={suggestion.icon}
|
|
263
|
+
alt="icon"
|
|
264
|
+
/>
|
|
265
|
+
)}
|
|
266
|
+
<div className="coz-searchbar-autosuggest-suggestion-content">
|
|
267
|
+
<div className="coz-searchbar-autosuggest-suggestion-title">
|
|
268
|
+
{highlightQueryTerms(suggestion.title, this.state.query)}
|
|
269
|
+
</div>
|
|
270
|
+
{suggestion.subtitle && (
|
|
271
|
+
<div className="coz-searchbar-autosuggest-suggestion-subtitle">
|
|
272
|
+
{highlightQueryTerms(suggestion.subtitle, this.state.query)}
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
render() {
|
|
280
|
+
const {
|
|
281
|
+
input,
|
|
282
|
+
query,
|
|
283
|
+
searching,
|
|
284
|
+
focused,
|
|
285
|
+
suggestionsBySource,
|
|
286
|
+
sourceURLs
|
|
287
|
+
} = this.state
|
|
288
|
+
if (sourceURLs.length === 0) return null
|
|
289
|
+
const { t } = this.props
|
|
290
|
+
|
|
291
|
+
const isInitialSearch = input !== '' && query === null
|
|
292
|
+
const hasSuggestions =
|
|
293
|
+
suggestionsBySource.reduce(
|
|
294
|
+
(totalSuggestions, suggestionSection) =>
|
|
295
|
+
totalSuggestions + suggestionSection.suggestions.length,
|
|
296
|
+
0
|
|
297
|
+
) > 0
|
|
298
|
+
|
|
299
|
+
const inputProps = {
|
|
300
|
+
placeholder: t('searchbar.placeholder'),
|
|
301
|
+
value: input,
|
|
302
|
+
onChange: this.onChange,
|
|
303
|
+
onFocus: () => this.changeFocusState(true),
|
|
304
|
+
onBlur: () => this.changeFocusState(false)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const theme = {
|
|
308
|
+
container:
|
|
309
|
+
'coz-searchbar-autosuggest-container' +
|
|
310
|
+
(searching ? ' --searching' : '') +
|
|
311
|
+
(focused ? ' --focused' : ''),
|
|
312
|
+
input: 'coz-searchbar-autosuggest-input',
|
|
313
|
+
inputFocused: 'coz-searchbar-autosuggest-input-focused',
|
|
314
|
+
suggestionsContainer: 'coz-searchbar-autosuggest-suggestions-container',
|
|
315
|
+
suggestionsContainerOpen:
|
|
316
|
+
'coz-searchbar-autosuggest-suggestions-container--open',
|
|
317
|
+
suggestionsList: 'coz-searchbar-autosuggest-suggestions-list',
|
|
318
|
+
suggestion: 'coz-searchbar-autosuggest-suggestion',
|
|
319
|
+
suggestionHighlighted: 'coz-searchbar-autosuggest-suggestion-highlighted',
|
|
320
|
+
sectionTitle: 'coz-searchbar-autosuggest-section-title'
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return (
|
|
324
|
+
<div className="coz-searchbar" role="search">
|
|
325
|
+
{sourceURLs.map((url, i) => (
|
|
326
|
+
<iframe
|
|
327
|
+
src={url}
|
|
328
|
+
style={{ visibility: 'hidden', height: '0px', width: '0px' }}
|
|
329
|
+
key={url + i}
|
|
330
|
+
/>
|
|
331
|
+
))}
|
|
332
|
+
<Autosuggest
|
|
333
|
+
theme={theme}
|
|
334
|
+
suggestions={suggestionsBySource}
|
|
335
|
+
multiSection
|
|
336
|
+
onSuggestionsFetchRequested={
|
|
337
|
+
this.debouncedOnSuggestionsFetchRequested
|
|
338
|
+
}
|
|
339
|
+
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
|
340
|
+
onSuggestionSelected={this.onSuggestionSelected}
|
|
341
|
+
getSuggestionValue={this.getSuggestionValue}
|
|
342
|
+
getSectionSuggestions={this.getSectionSuggestions}
|
|
343
|
+
renderSectionTitle={this.renderSectionTitle}
|
|
344
|
+
renderSuggestion={this.renderSuggestion}
|
|
345
|
+
inputProps={inputProps}
|
|
346
|
+
focusInputOnSuggestionClick={false}
|
|
347
|
+
/>
|
|
348
|
+
{input !== '' && !isInitialSearch && focused && !hasSuggestions && (
|
|
349
|
+
<div className={'coz-searchbar-autosuggest-status-container'}>
|
|
350
|
+
{t('searchbar.empty', { query: input })}
|
|
351
|
+
</div>
|
|
352
|
+
)}
|
|
353
|
+
</div>
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export default translate()(SearchBar)
|