• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

EcrituresNumeriques / stylo / 13547287365

26 Feb 2025 03:20PM UTC coverage: 12.024% (+0.3%) from 11.684%
13547287365

push

github

web-flow
Merge pull request #1255 from ggrossetie/fix-1171-hooks-renaming

157 of 305 branches covered (51.48%)

Branch coverage included in aggregate %.

67 of 264 new or added lines in 25 files covered. (25.38%)

10 existing lines in 4 files now uncovered.

1144 of 10515 relevant lines covered (10.88%)

1.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/front/src/components/Write/Write.jsx
1
import {
×
2
  Code,
3
  Modal as GeistModal,
4
  Text,
5
  useModal,
6
  useToasts,
7
} from '@geist-ui/core'
8
import React, { useCallback, useEffect, useMemo, useState } from 'react'
×
9
import { useTranslation } from 'react-i18next'
×
10
import { Switch, Route, useRouteMatch } from 'react-router-dom'
×
11
import { batch, shallowEqual, useDispatch, useSelector } from 'react-redux'
×
12
import { useParams } from 'react-router-dom'
×
13
import PropTypes from 'prop-types'
×
14
import throttle from 'lodash.throttle'
×
15
import debounce from 'lodash.debounce'
×
16
import { useMutation } from '../../hooks/graphql.js'
×
17
import { applicationConfig } from '../../config.js'
×
18
import ArticleStats from '../ArticleStats.jsx'
×
19
import ErrorMessageCard from '../ErrorMessageCard.jsx'
×
20

21
import styles from './write.module.scss'
×
22

23
import { useActiveUserId } from '../../hooks/user'
×
NEW
24
import { useGraphQLClient } from '../../helpers/graphQL'
×
NEW
25
import {
×
26
  getEditableArticle as getEditableArticleQuery,
27
  stopSoloSession,
28
} from './Write.graphql'
29

30
import ArticleEditorMenu from './ArticleEditorMenu.jsx'
×
31
import ArticleEditorMetadata from './ArticleEditorMetadata.jsx'
×
32
import WorkingVersion from './WorkingVersion'
×
33
import PreviewHtml from './PreviewHtml'
×
34
import PreviewPaged from './PreviewPaged'
×
35
import Loading from '../Loading'
×
36
import MonacoEditor from './providers/monaco/Editor'
×
37
import clsx from 'clsx'
×
38
import { Helmet } from 'react-helmet'
×
39

40
const MODES_PREVIEW = 'preview'
×
41
const MODES_READONLY = 'readonly'
×
42
const MODES_WRITE = 'write'
×
43

44
export function deriveModeFrom({ path, currentVersion }) {
×
45
  if (path === '/article/:id/preview') {
×
46
    return MODES_PREVIEW
×
47
  } else if (currentVersion) {
×
48
    return MODES_READONLY
×
49
  }
×
50

51
  return MODES_WRITE
×
52
}
×
53

54
export default function Write() {
×
55
  const { setToast } = useToasts()
×
56
  const { backendEndpoint } = applicationConfig
×
57
  const { t } = useTranslation()
×
58
  const { version: currentVersion, id: articleId, compareTo } = useParams()
×
59
  const workingArticle = useSelector(
×
60
    (state) => state.workingArticle,
×
61
    shallowEqual
×
62
  )
×
63
  const userId = useActiveUserId()
×
64
  const dispatch = useDispatch()
×
NEW
65
  const { query } = useGraphQLClient()
×
66
  const routeMatch = useRouteMatch()
×
67
  const [collaborativeSessionActive, setCollaborativeSessionActive] =
×
68
    useState(false)
×
69
  const [soloSessionActive, setSoloSessionActive] = useState(false)
×
70
  const mode = useMemo(() => {
×
71
    if (collaborativeSessionActive || soloSessionActive) {
×
72
      return MODES_READONLY
×
73
    }
×
74
    return deriveModeFrom({ currentVersion, path: routeMatch.path })
×
75
  }, [
×
76
    currentVersion,
×
77
    routeMatch.path,
×
78
    collaborativeSessionActive,
×
79
    soloSessionActive,
×
80
  ])
×
81
  const [graphQLError, setGraphQLError] = useState()
×
82
  const [isLoading, setIsLoading] = useState(true)
×
83
  const [live, setLive] = useState({})
×
84
  const [soloSessionTakenOverBy, setSoloSessionTakenOverBy] = useState('')
×
85
  const [articleInfos, setArticleInfos] = useState({
×
86
    title: '',
×
87
    owner: '',
×
88
    contributors: [],
×
89
    zoteroLink: '',
×
90
    preview: {},
×
91
  })
×
92

93
  const mutation = useMutation()
×
94

95
  const {
×
96
    visible: collaborativeSessionActiveVisible,
×
97
    setVisible: setCollaborativeSessionActiveVisible,
×
98
    bindings: collaborativeSessionActiveBinding,
×
99
  } = useModal()
×
100

101
  const {
×
102
    visible: soloSessionActiveVisible,
×
103
    setVisible: setSoloSessionActiveVisible,
×
104
    bindings: soloSessionActiveBinding,
×
105
  } = useModal()
×
106

107
  const {
×
108
    visible: soloSessionTakeOverModalVisible,
×
109
    setVisible: setSoloSessionTakeOverModalVisible,
×
110
    bindings: soloSessionTakeOverModalBinding,
×
111
  } = useModal()
×
112

113
  const PreviewComponent = useMemo(
×
114
    () => (articleInfos.preview.stylesheet ? PreviewPaged : PreviewHtml),
×
115
    [articleInfos.preview.stylesheet, currentVersion]
×
116
  )
×
117

118
  const deriveArticleStructureAndStats = useCallback(
×
119
    throttle(
×
120
      ({ text }) => {
×
121
        dispatch({ type: 'UPDATE_ARTICLE_STATS', md: text })
×
122
        dispatch({ type: 'UPDATE_ARTICLE_STRUCTURE', md: text })
×
123
      },
×
124
      250,
×
125
      { leading: false, trailing: true }
×
126
    ),
×
127
    []
×
128
  )
×
129
  const setWorkingArticleDirty = useCallback(
×
130
    debounce(
×
131
      async () => {
×
132
        dispatch({
×
133
          type: 'SET_WORKING_ARTICLE_STATE',
×
134
          workingArticleState: 'saving',
×
135
        })
×
136
      },
×
137
      1000,
×
138
      { leading: true, trailing: false }
×
139
    ),
×
140
    []
×
141
  )
×
142
  const updateWorkingArticleText = useCallback(
×
143
    debounce(
×
144
      async ({ text }) => {
×
145
        dispatch({ type: 'UPDATE_WORKING_ARTICLE_TEXT', articleId, text })
×
146
      },
×
147
      1000,
×
148
      { leading: false, trailing: true }
×
149
    ),
×
150
    []
×
151
  )
×
152
  const updateWorkingArticleMetadata = useCallback(
×
153
    debounce(
×
154
      ({ metadata }) => {
×
155
        dispatch({
×
156
          type: 'UPDATE_WORKING_ARTICLE_METADATA',
×
157
          articleId,
×
158
          metadata,
×
159
        })
×
160
      },
×
161
      1000,
×
162
      { leading: false, trailing: true }
×
163
    ),
×
164
    []
×
165
  )
×
166

167
  const handleMDCM = (text) => {
×
168
    deriveArticleStructureAndStats({ text })
×
169
    updateWorkingArticleText({ text })
×
170
    setWorkingArticleDirty()
×
171
    return setLive({ ...live, md: text })
×
172
  }
×
173

174
  const handleMetadataChange = (metadata) => {
×
175
    updateWorkingArticleMetadata({ metadata })
×
176
    setWorkingArticleDirty()
×
177
    return setLive({ ...live, metadata })
×
178
  }
×
179

180
  const handleStateUpdated = useCallback(
×
181
    (event) => {
×
182
      const parsedData = JSON.parse(event.data)
×
183
      if (parsedData.articleStateUpdated) {
×
184
        const articleStateUpdated = parsedData.articleStateUpdated
×
185
        if (articleId === articleStateUpdated._id) {
×
186
          if (
×
187
            articleStateUpdated.soloSession &&
×
188
            articleStateUpdated.soloSession.id
×
189
          ) {
×
190
            if (userId !== articleStateUpdated.soloSession.creator._id) {
×
191
              setSoloSessionTakenOverBy(
×
192
                articleStateUpdated.soloSession.creatorUsername
×
193
              )
×
194
              setSoloSessionActive(true)
×
195
              setSoloSessionTakeOverModalVisible(true)
×
196
            }
×
197
          } else if (articleStateUpdated.collaborativeSession) {
×
198
            setCollaborativeSessionActiveVisible(true)
×
199
            setCollaborativeSessionActive(true)
×
200
          }
×
201
        }
×
202
      }
×
203
    },
×
204
    [articleId]
×
205
  )
×
206

207
  useEffect(() => {
×
208
    // FIXME: should retrieve extensions.type 'COLLABORATIVE_SESSION_CONFLICT'
209
    if (
×
210
      workingArticle &&
×
211
      workingArticle.state === 'saveFailure' &&
×
212
      workingArticle.stateMessage ===
×
213
        'Active collaborative session, cannot update the working copy.'
×
214
    ) {
×
215
      setCollaborativeSessionActiveVisible(true)
×
216
      setCollaborativeSessionActive(true)
×
217
    }
×
218
  }, [workingArticle])
×
219

220
  // Reload when version switching
221
  useEffect(() => {
×
222
    const variables = {
×
223
      user: userId,
×
224
      article: articleId,
×
225
      version: currentVersion || 'latest',
×
226
      hasVersion: typeof currentVersion === 'string',
×
227
      isPreview: mode === MODES_PREVIEW,
×
228
    }
×
229

230
    setIsLoading(true)
×
231
    ;(async () => {
×
NEW
232
      const data = await query({
×
NEW
233
        query: getEditableArticleQuery,
×
NEW
234
        variables,
×
NEW
235
      }).catch((error) => {
×
236
        setGraphQLError(error)
×
237
        return {}
×
238
      })
×
239

240
      if (data?.article) {
×
241
        if (data.article.soloSession && data.article.soloSession.id) {
×
242
          if (userId !== data.article.soloSession.creator._id) {
×
243
            setSoloSessionActive(true)
×
244
            setSoloSessionActiveVisible(true)
×
245
          }
×
246
        }
×
247
        setCollaborativeSessionActive(
×
248
          data.article.collaborativeSession &&
×
249
            data.article.collaborativeSession.id
×
250
        )
×
251
        setCollaborativeSessionActiveVisible(
×
252
          data.article.collaborativeSession &&
×
253
            data.article.collaborativeSession.id
×
254
        )
×
255
        const article = data.article
×
256
        let currentArticle
×
257
        if (currentVersion) {
×
258
          currentArticle = {
×
259
            bib: data.version.bib,
×
260
            md: data.version.md,
×
261
            metadata: data.version.metadata,
×
262
            bibPreview: data.version.bibPreview,
×
263
            version: {
×
264
              message: data.version.message,
×
265
              major: data.version.version,
×
266
              minor: data.version.revision,
×
267
            },
×
268
          }
×
269
        } else {
×
270
          currentArticle = article.workingVersion
×
271
        }
×
272
        setLive(currentArticle)
×
273
        setArticleInfos({
×
274
          _id: article._id,
×
275
          title: article.title,
×
276
          owner: article.owner,
×
277
          contributors: article.contributors,
×
278
          zoteroLink: article.zoteroLink,
×
279
          preview: article.preview,
×
280
          updatedAt: article.updatedAt,
×
281
        })
×
282

283
        const { md, bib, metadata } = currentArticle
×
284

285
        batch(() => {
×
286
          dispatch({ type: 'SET_ARTICLE_VERSIONS', versions: article.versions })
×
287
          dispatch({ type: 'UPDATE_ARTICLE_STATS', md })
×
288
          dispatch({ type: 'UPDATE_ARTICLE_STRUCTURE', md })
×
289
          dispatch({ type: 'SET_WORKING_ARTICLE_TEXT', text: md })
×
290
          dispatch({ type: 'SET_WORKING_ARTICLE_METADATA', metadata })
×
291
          dispatch({
×
292
            type: 'SET_WORKING_ARTICLE_BIBLIOGRAPHY',
×
293
            bibliography: bib,
×
294
          })
×
295
          dispatch({
×
296
            type: 'SET_WORKING_ARTICLE_UPDATED_AT',
×
297
            updatedAt: article.updatedAt,
×
298
          })
×
299
        })
×
300
      }
×
301

302
      setIsLoading(false)
×
303
    })()
×
304

305
    return async () => {
×
306
      try {
×
307
        await mutation({ query: stopSoloSession, variables: { articleId } })
×
308
      } catch (err) {
×
309
        if (
×
310
          err &&
×
311
          err.messages &&
×
312
          err.messages.length > 0 &&
×
313
          err.messages[0].extensions &&
×
314
          err.messages[0].extensions.type === 'UNAUTHORIZED'
×
315
        ) {
×
316
          // cannot end solo session... ignoring
317
        } else {
×
318
          setToast({
×
319
            type: 'error',
×
320
            text: `Unable to end solo session: ${err.toString()}`,
×
321
          })
×
322
        }
×
323
      }
×
324
    }
×
325
  }, [currentVersion, articleId])
×
326

327
  useEffect(() => {
×
328
    let events
×
329
    if (!isLoading) {
×
330
      events = new EventSource(`${backendEndpoint}/events?userId=${userId}`)
×
331
      events.onmessage = (event) => {
×
332
        handleStateUpdated(event)
×
333
      }
×
334
    }
×
335
    return () => {
×
336
      if (events) {
×
337
        events.close()
×
338
      }
×
339
    }
×
340
  }, [isLoading, handleStateUpdated])
×
341

342
  if (graphQLError) {
×
343
    return (
×
344
      <section className={styles.errorContainer}>
×
345
        <ErrorMessageCard title="Error">
×
346
          <Text>
×
347
            <Code>{graphQLError?.message || graphQLError.toString()}</Code>
×
348
          </Text>
×
349
        </ErrorMessageCard>
×
350
      </section>
×
351
    )
352
  }
×
353

354
  if (isLoading) {
×
355
    return <Loading />
×
356
  }
×
357

358
  return (
×
359
    <section className={styles.container}>
×
360
      <Helmet>
×
361
        <title>{t('article.page.title', { title: articleInfos.title })}</title>
×
362
      </Helmet>
×
363
      <GeistModal
×
364
        width="40rem"
×
365
        visible={collaborativeSessionActiveVisible}
×
366
        {...collaborativeSessionActiveBinding}
×
367
      >
368
        <h2>{t('article.collaborativeSessionActive.title')}</h2>
×
369
        <GeistModal.Content>
×
370
          {t('article.collaborativeSessionActive.message')}
×
371
        </GeistModal.Content>
×
372
        <GeistModal.Action
×
373
          onClick={() => setCollaborativeSessionActiveVisible(false)}
×
374
        >
375
          {t('modal.confirmButton.text')}
×
376
        </GeistModal.Action>
×
377
      </GeistModal>
×
378

379
      <GeistModal
×
380
        width="40rem"
×
381
        visible={soloSessionActiveVisible}
×
382
        {...soloSessionActiveBinding}
×
383
      >
384
        <h2>{t('article.soloSessionActive.title')}</h2>
×
385
        <GeistModal.Content>
×
386
          {t('article.soloSessionActive.message')}
×
387
        </GeistModal.Content>
×
388
        <GeistModal.Action onClick={() => setSoloSessionActiveVisible(false)}>
×
389
          {t('modal.confirmButton.text')}
×
390
        </GeistModal.Action>
×
391
      </GeistModal>
×
392

393
      <GeistModal
×
394
        width="40rem"
×
395
        visible={soloSessionTakeOverModalVisible}
×
396
        {...soloSessionTakeOverModalBinding}
×
397
      >
398
        <h2>{t('article.soloSessionTakeOver.title')}</h2>
×
399
        <GeistModal.Content>
×
400
          {t('article.soloSessionTakeOver.message', {
×
401
            username: soloSessionTakenOverBy,
×
402
          })}
×
403
        </GeistModal.Content>
×
404
        <GeistModal.Action
×
405
          onClick={() => setSoloSessionTakeOverModalVisible(false)}
×
406
        >
407
          {t('modal.confirmButton.text')}
×
408
        </GeistModal.Action>
×
409
      </GeistModal>
×
410

411
      <ArticleEditorMenu
×
412
        articleInfos={articleInfos}
×
413
        compareTo={compareTo}
×
414
        selectedVersion={currentVersion}
×
415
        readOnly={mode === MODES_READONLY}
×
416
      />
×
417
      <article className={clsx({ [styles.article]: mode !== MODES_PREVIEW })}>
×
418
        <WorkingVersion
×
419
          articleInfos={articleInfos}
×
420
          live={live}
×
421
          selectedVersion={currentVersion}
×
422
          mode={mode}
×
423
        />
×
424

425
        <Switch>
×
426
          <Route path="*/preview" exact>
×
427
            <PreviewComponent
×
428
              preview={articleInfos.preview}
×
429
              metadata={live.metadata}
×
430
            />
×
431
          </Route>
×
432
          <Route path="*">
×
433
            <MonacoEditor
×
434
              text={live.md}
×
435
              readOnly={mode === MODES_READONLY}
×
436
              onTextUpdate={handleMDCM}
×
437
              articleId={articleInfos._id}
×
438
              selectedVersion={currentVersion}
×
439
              compareTo={compareTo}
×
440
              currentArticleVersion={live.version}
×
441
            />
×
442

443
            <ArticleStats />
×
444
          </Route>
×
445
        </Switch>
×
446
      </article>
×
447
      <ArticleEditorMetadata
×
448
        metadata={live.metadata}
×
449
        onChange={handleMetadataChange}
×
450
        readOnly={mode === MODES_READONLY}
×
451
      />
×
452
    </section>
×
453
  )
454
}
×
455

456
Write.propTypes = {
×
457
  version: PropTypes.string,
×
458
  id: PropTypes.string,
×
459
  compareTo: PropTypes.string,
×
460
}
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc