cozy-bar 0.0.0-development

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.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +174 -0
  3. package/dist/cozy-bar.min.js +77 -0
  4. package/dist/cozy-bar.min.js.map +1 -0
  5. package/package.json +165 -0
  6. package/src/assets/icons/16/icon-storage-16.svg +3 -0
  7. package/src/assets/icons/24/icon-arrow-left.svg +3 -0
  8. package/src/assets/icons/32/icon-claudy.svg +1 -0
  9. package/src/assets/icons/apps/icon-collect.svg +25 -0
  10. package/src/assets/icons/apps/icon-drive.svg +17 -0
  11. package/src/assets/icons/apps/icon-market-soon.svg +25 -0
  12. package/src/assets/icons/apps/icon-photos.svg +19 -0
  13. package/src/assets/icons/apps/icon-soon.svg +21 -0
  14. package/src/assets/icons/apps/icon-store.svg +19 -0
  15. package/src/assets/icons/claudyActions/icon-bills.svg +6 -0
  16. package/src/assets/icons/claudyActions/icon-laptop.svg +7 -0
  17. package/src/assets/icons/claudyActions/icon-phone.svg +8 -0
  18. package/src/assets/icons/claudyActions/icon-question-mark.svg +6 -0
  19. package/src/assets/icons/comingsoon/icon-bank.svg +12 -0
  20. package/src/assets/icons/comingsoon/icon-sante.svg +12 -0
  21. package/src/assets/icons/comingsoon/icon-store.svg +6 -0
  22. package/src/assets/icons/icon-cozy.svg +3 -0
  23. package/src/assets/icons/icon-shield.svg +3 -0
  24. package/src/assets/icons/spinner.svg +4 -0
  25. package/src/assets/sprites/icon-apps.svg +1 -0
  26. package/src/assets/sprites/icon-cozy-home.svg +16 -0
  27. package/src/components/Apps/AppItem.jsx +117 -0
  28. package/src/components/Apps/AppItemPlaceholder.jsx +12 -0
  29. package/src/components/Apps/AppNavButtons.jsx +94 -0
  30. package/src/components/Apps/AppsContent.jsx +91 -0
  31. package/src/components/Apps/ButtonCozyHome.jsx +30 -0
  32. package/src/components/Apps/ButtonCozyHome.spec.jsx +53 -0
  33. package/src/components/Apps/IconCozyHome.jsx +38 -0
  34. package/src/components/Apps/index.jsx +72 -0
  35. package/src/components/Banner.jsx +41 -0
  36. package/src/components/Bar.jsx +295 -0
  37. package/src/components/Bar.spec.jsx +133 -0
  38. package/src/components/Claudy.jsx +81 -0
  39. package/src/components/ClaudyIcon.jsx +18 -0
  40. package/src/components/Drawer.jsx +227 -0
  41. package/src/components/Drawer.spec.jsx +98 -0
  42. package/src/components/SearchBar.jsx +358 -0
  43. package/src/components/Settings/SettingsContent.jsx +163 -0
  44. package/src/components/Settings/StorageData.jsx +29 -0
  45. package/src/components/Settings/helper.js +8 -0
  46. package/src/components/Settings/index.jsx +220 -0
  47. package/src/components/StorageIcon.jsx +16 -0
  48. package/src/components/SupportModal.jsx +59 -0
  49. package/src/components/__snapshots__/Bar.spec.jsx.snap +302 -0
  50. package/src/config/claudyActions.json +20 -0
  51. package/src/config/persistWhitelist.json +4 -0
  52. package/src/dom.js +80 -0
  53. package/src/index.jsx +242 -0
  54. package/src/index.spec.jsx +34 -0
  55. package/src/lib/api/helpers.js +13 -0
  56. package/src/lib/api/index.jsx +145 -0
  57. package/src/lib/exceptions.js +89 -0
  58. package/src/lib/expiringMemoize.js +13 -0
  59. package/src/lib/icon.js +77 -0
  60. package/src/lib/intents.js +16 -0
  61. package/src/lib/logger.js +11 -0
  62. package/src/lib/middlewares/appsI18n.js +57 -0
  63. package/src/lib/realtime.js +43 -0
  64. package/src/lib/reducers/apps.js +175 -0
  65. package/src/lib/reducers/apps.spec.js +59 -0
  66. package/src/lib/reducers/content.js +50 -0
  67. package/src/lib/reducers/context.js +86 -0
  68. package/src/lib/reducers/index.js +73 -0
  69. package/src/lib/reducers/locale.js +22 -0
  70. package/src/lib/reducers/settings.js +111 -0
  71. package/src/lib/reducers/theme.js +48 -0
  72. package/src/lib/reducers/unserializable.js +26 -0
  73. package/src/lib/stack-client.js +401 -0
  74. package/src/lib/stack.js +79 -0
  75. package/src/lib/store/index.js +44 -0
  76. package/src/locales/de.json +57 -0
  77. package/src/locales/en.json +57 -0
  78. package/src/locales/es.json +57 -0
  79. package/src/locales/fr.json +57 -0
  80. package/src/locales/it.json +57 -0
  81. package/src/locales/ja.json +57 -0
  82. package/src/locales/nl_NL.json +57 -0
  83. package/src/locales/pl.json +57 -0
  84. package/src/locales/ru.json +57 -0
  85. package/src/locales/sq.json +57 -0
  86. package/src/locales/zh_CN.json +57 -0
  87. package/src/proptypes/index.js +10 -0
  88. package/src/queries/index.js +16 -0
  89. package/src/styles/apps.css +248 -0
  90. package/src/styles/banner.css +64 -0
  91. package/src/styles/bar.css +106 -0
  92. package/src/styles/base.css +21 -0
  93. package/src/styles/claudy.css +98 -0
  94. package/src/styles/drawer.css +126 -0
  95. package/src/styles/index.styl +33 -0
  96. package/src/styles/indicators.css +58 -0
  97. package/src/styles/nav.css +81 -0
  98. package/src/styles/navigation_item.css +34 -0
  99. package/src/styles/searchbar.css +156 -0
  100. package/src/styles/settings.css +34 -0
  101. package/src/styles/storage.css +22 -0
  102. package/src/styles/supportModal.css +20 -0
  103. package/src/styles/theme.styl +25 -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)