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

EcrituresNumeriques / stylo / 13987938500

21 Mar 2025 08:48AM UTC coverage: 31.659% (+0.6%) from 31.087%
13987938500

push

github

web-flow
chore: remplace les modales Geist (#1358)

Remplace aussi <Loading> par notre composant.

464 of 686 branches covered (67.64%)

Branch coverage included in aggregate %.

42 of 569 new or added lines in 36 files covered. (7.38%)

27 existing lines in 18 files now uncovered.

4396 of 14665 relevant lines covered (29.98%)

2.26 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 React, { useCallback, useEffect, useMemo, useState } from 'react'
×
NEW
2
import { Code, Text, useToasts } from '@geist-ui/core'
×
NEW
3
import clsx from 'clsx'
×
NEW
4
import debounce from 'lodash.debounce'
×
NEW
5
import throttle from 'lodash.throttle'
×
NEW
6
import { Helmet } from 'react-helmet'
×
7
import { useTranslation } from 'react-i18next'
×
8
import { batch, shallowEqual, useDispatch, useSelector } from 'react-redux'
×
NEW
9
import { Route, Switch, useParams, useRouteMatch } from 'react-router-dom'
×
10

11
import { applicationConfig } from '../../config.js'
×
NEW
12
import { useGraphQLClient } from '../../helpers/graphQL'
×
NEW
13
import { useModal } from '../../hooks/modal.js'
×
NEW
14
import { useActiveUserId } from '../../hooks/user'
×
15

16
import ArticleStats from '../ArticleStats.jsx'
×
17
import ErrorMessageCard from '../ErrorMessageCard.jsx'
×
NEW
18
import Modal from '../Modal.jsx'
×
NEW
19
import FormActions from '../molecules/FormActions.jsx'
×
NEW
20
import Loading from '../molecules/Loading.jsx'
×
NEW
21
import ArticleEditorMenu from './ArticleEditorMenu.jsx'
×
NEW
22
import ArticleEditorMetadata from './ArticleEditorMetadata.jsx'
×
23

NEW
24
import PreviewHtml from './PreviewHtml'
×
NEW
25
import PreviewPaged from './PreviewPaged'
×
NEW
26
import MonacoEditor from './providers/monaco/Editor'
×
NEW
27
import WorkingVersion from './WorkingVersion'
×
28

29
import {
×
30
  getEditableArticle as getEditableArticleQuery,
31
  stopSoloSession,
32
} from './Write.graphql'
33

NEW
34
import styles from './write.module.scss'
×
35

36
const MODES_PREVIEW = 'preview'
×
37
const MODES_READONLY = 'readonly'
×
38
const MODES_WRITE = 'write'
×
39

40
export function deriveModeFrom({ path, currentVersion }) {
×
41
  if (path === '/article/:id/preview') {
×
42
    return MODES_PREVIEW
×
43
  } else if (currentVersion) {
×
44
    return MODES_READONLY
×
45
  }
×
46

47
  return MODES_WRITE
×
48
}
×
49

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

NEW
92
  const collaborativeSessionActiveModal = useModal()
×
NEW
93
  const soloSessionActiveModal = useModal()
×
NEW
94
  const soloSessionTakeOverModal = useModal()
×
95

96
  const PreviewComponent = useMemo(
×
97
    () => (articleInfos.preview.stylesheet ? PreviewPaged : PreviewHtml),
×
98
    [articleInfos.preview.stylesheet, currentVersion]
×
99
  )
×
100

101
  const deriveArticleStructureAndStats = useCallback(
×
102
    throttle(
×
103
      ({ text }) => {
×
104
        dispatch({ type: 'UPDATE_ARTICLE_STATS', md: text })
×
105
        dispatch({ type: 'UPDATE_ARTICLE_STRUCTURE', md: text })
×
106
      },
×
107
      250,
×
108
      { leading: false, trailing: true }
×
109
    ),
×
110
    []
×
111
  )
×
112
  const setWorkingArticleDirty = useCallback(
×
113
    debounce(
×
114
      async () => {
×
115
        dispatch({
×
116
          type: 'SET_WORKING_ARTICLE_STATE',
×
117
          workingArticleState: 'saving',
×
118
        })
×
119
      },
×
120
      1000,
×
121
      { leading: true, trailing: false }
×
122
    ),
×
123
    []
×
124
  )
×
125
  const updateWorkingArticleText = useCallback(
×
126
    debounce(
×
127
      async ({ text }) => {
×
128
        dispatch({ type: 'UPDATE_WORKING_ARTICLE_TEXT', articleId, text })
×
129
      },
×
130
      1000,
×
131
      { leading: false, trailing: true }
×
132
    ),
×
133
    []
×
134
  )
×
135
  const updateWorkingArticleMetadata = useCallback(
×
136
    debounce(
×
137
      ({ metadata }) => {
×
138
        dispatch({
×
139
          type: 'UPDATE_WORKING_ARTICLE_METADATA',
×
140
          articleId,
×
141
          metadata,
×
142
        })
×
143
      },
×
144
      1000,
×
145
      { leading: false, trailing: true }
×
146
    ),
×
147
    []
×
148
  )
×
149

150
  const handleMDCM = (text) => {
×
151
    deriveArticleStructureAndStats({ text })
×
152
    updateWorkingArticleText({ text })
×
153
    setWorkingArticleDirty()
×
154
    return setLive({ ...live, md: text })
×
155
  }
×
156

157
  const handleMetadataChange = (metadata) => {
×
158
    updateWorkingArticleMetadata({ metadata })
×
159
    setWorkingArticleDirty()
×
160
    return setLive({ ...live, metadata })
×
161
  }
×
162

163
  const handleStateUpdated = useCallback(
×
164
    (event) => {
×
165
      const parsedData = JSON.parse(event.data)
×
166
      if (parsedData.articleStateUpdated) {
×
167
        const articleStateUpdated = parsedData.articleStateUpdated
×
168
        if (articleId === articleStateUpdated._id) {
×
169
          if (
×
170
            articleStateUpdated.soloSession &&
×
171
            articleStateUpdated.soloSession.id
×
172
          ) {
×
173
            if (userId !== articleStateUpdated.soloSession.creator._id) {
×
174
              setSoloSessionTakenOverBy(
×
175
                articleStateUpdated.soloSession.creatorUsername
×
176
              )
×
177
              setSoloSessionActive(true)
×
NEW
178
              soloSessionTakeOverModal.show()
×
179
            }
×
180
          } else if (articleStateUpdated.collaborativeSession) {
×
NEW
181
            collaborativeSessionActiveModal.show()
×
182
            setCollaborativeSessionActive(true)
×
183
          }
×
184
        }
×
185
      }
×
186
    },
×
187
    [articleId]
×
188
  )
×
189

190
  useEffect(() => {
×
191
    // FIXME: should retrieve extensions.type 'COLLABORATIVE_SESSION_CONFLICT'
192
    if (
×
193
      workingArticle &&
×
194
      workingArticle.state === 'saveFailure' &&
×
195
      workingArticle.stateMessage ===
×
196
        'Active collaborative session, cannot update the working copy.'
×
197
    ) {
×
NEW
198
      collaborativeSessionActiveModal.show()
×
199
      setCollaborativeSessionActive(true)
×
200
    }
×
201
  }, [workingArticle])
×
202

203
  // Reload when version switching
204
  useEffect(() => {
×
205
    const variables = {
×
206
      user: userId,
×
207
      article: articleId,
×
208
      version: currentVersion || 'latest',
×
209
      hasVersion: typeof currentVersion === 'string',
×
210
      isPreview: mode === MODES_PREVIEW,
×
211
    }
×
212

213
    setIsLoading(true)
×
214
    ;(async () => {
×
215
      const data = await query({
×
216
        query: getEditableArticleQuery,
×
217
        variables,
×
218
      }).catch((error) => {
×
219
        setGraphQLError(error)
×
220
        return {}
×
221
      })
×
222

223
      if (data?.article) {
×
224
        if (data.article.soloSession && data.article.soloSession.id) {
×
225
          if (userId !== data.article.soloSession.creator._id) {
×
226
            setSoloSessionActive(true)
×
NEW
227
            soloSessionActiveModal.show()
×
228
          }
×
229
        }
×
230
        setCollaborativeSessionActive(
×
231
          data.article.collaborativeSession &&
×
232
            data.article.collaborativeSession.id
×
233
        )
×
NEW
234
        const collaborativeSessionActiveModalVisible =
×
235
          data.article.collaborativeSession &&
×
NEW
236
          data.article.collaborativeSession.id
×
NEW
237
        if (collaborativeSessionActiveModalVisible) {
×
NEW
238
          collaborativeSessionActiveModal.show()
×
NEW
239
        }
×
240
        const article = data.article
×
241
        let currentArticle
×
242
        if (currentVersion) {
×
243
          currentArticle = {
×
244
            bib: data.version.bib,
×
245
            md: data.version.md,
×
246
            metadata: data.version.metadata,
×
247
            bibPreview: data.version.bibPreview,
×
248
            version: {
×
249
              message: data.version.message,
×
250
              major: data.version.version,
×
251
              minor: data.version.revision,
×
252
            },
×
253
          }
×
254
        } else {
×
255
          currentArticle = article.workingVersion
×
256
        }
×
257
        setLive(currentArticle)
×
258
        setArticleInfos({
×
259
          _id: article._id,
×
260
          title: article.title,
×
261
          owner: article.owner,
×
262
          contributors: article.contributors,
×
263
          zoteroLink: article.zoteroLink,
×
264
          preview: article.preview,
×
265
          updatedAt: article.updatedAt,
×
266
        })
×
267

268
        const { md, bib, metadata } = currentArticle
×
269

270
        batch(() => {
×
271
          dispatch({ type: 'SET_ARTICLE_VERSIONS', versions: article.versions })
×
272
          dispatch({ type: 'UPDATE_ARTICLE_STATS', md })
×
273
          dispatch({ type: 'UPDATE_ARTICLE_STRUCTURE', md })
×
274
          dispatch({ type: 'SET_WORKING_ARTICLE_TEXT', text: md })
×
275
          dispatch({ type: 'SET_WORKING_ARTICLE_METADATA', metadata })
×
276
          dispatch({
×
277
            type: 'SET_WORKING_ARTICLE_BIBLIOGRAPHY',
×
278
            bibliography: bib,
×
279
          })
×
280
          dispatch({
×
281
            type: 'SET_WORKING_ARTICLE_UPDATED_AT',
×
282
            updatedAt: article.updatedAt,
×
283
          })
×
284
        })
×
285
      }
×
286

287
      setIsLoading(false)
×
288
    })()
×
289

290
    return async () => {
×
291
      try {
×
292
        await query({ query: stopSoloSession, variables: { articleId } })
×
293
      } catch (err) {
×
294
        if (
×
295
          err &&
×
296
          err.messages &&
×
297
          err.messages.length > 0 &&
×
298
          err.messages[0].extensions &&
×
299
          err.messages[0].extensions.type === 'UNAUTHORIZED'
×
300
        ) {
×
301
          // cannot end solo session... ignoring
302
        } else {
×
303
          setToast({
×
304
            type: 'error',
×
305
            text: `Unable to end solo session: ${err.toString()}`,
×
306
          })
×
307
        }
×
308
      }
×
309
    }
×
310
  }, [currentVersion, articleId])
×
311

312
  useEffect(() => {
×
313
    let events
×
314
    if (!isLoading) {
×
315
      events = new EventSource(`${backendEndpoint}/events?userId=${userId}`)
×
316
      events.onmessage = (event) => {
×
317
        handleStateUpdated(event)
×
318
      }
×
319
    }
×
320
    return () => {
×
321
      if (events) {
×
322
        events.close()
×
323
      }
×
324
    }
×
325
  }, [isLoading, handleStateUpdated])
×
326

327
  if (graphQLError) {
×
328
    return (
×
329
      <section className={styles.errorContainer}>
×
330
        <ErrorMessageCard title="Error">
×
331
          <Text>
×
332
            <Code>{graphQLError?.message || graphQLError.toString()}</Code>
×
333
          </Text>
×
334
        </ErrorMessageCard>
×
335
      </section>
×
336
    )
337
  }
×
338

339
  if (isLoading) {
×
340
    return <Loading />
×
341
  }
×
342

343
  return (
×
344
    <section className={styles.container}>
×
345
      <Helmet>
×
346
        <title>{t('article.page.title', { title: articleInfos.title })}</title>
×
347
      </Helmet>
×
NEW
348
      <Modal
×
NEW
349
        {...collaborativeSessionActiveModal.bindings}
×
NEW
350
        title={t('article.collaborativeSessionActive.title')}
×
351
      >
NEW
352
        {t('article.collaborativeSessionActive.message')}
×
NEW
353
        <FormActions
×
NEW
354
          onCancel={() => collaborativeSessionActiveModal.close()}
×
NEW
355
          onSubmit={() => collaborativeSessionActiveModal.close()}
×
NEW
356
          submitButton={{
×
NEW
357
            text: t('modal.confirmButton.text'),
×
NEW
358
            title: t('modal.confirmButton.text'),
×
NEW
359
          }}
×
NEW
360
        />
×
NEW
361
      </Modal>
×
362

NEW
363
      <Modal
×
NEW
364
        {...soloSessionActiveModal.bindings}
×
NEW
365
        title={t('article.soloSessionActive.title')}
×
366
      >
NEW
367
        {t('article.soloSessionActive.message')}
×
NEW
368
        <FormActions
×
NEW
369
          onCancel={() => soloSessionActiveModal.close()}
×
NEW
370
          onSubmit={() => soloSessionActiveModal.close()}
×
NEW
371
          submitButton={{
×
NEW
372
            text: t('modal.confirmButton.text'),
×
NEW
373
            title: t('modal.confirmButton.text'),
×
NEW
374
          }}
×
NEW
375
        />
×
NEW
376
      </Modal>
×
377

NEW
378
      <Modal
×
NEW
379
        {...soloSessionTakeOverModal.bindings}
×
NEW
380
        title={t('article.soloSessionTakeOver.title')}
×
381
      >
NEW
382
        {t('article.soloSessionTakeOver.message', {
×
NEW
383
          username: soloSessionTakenOverBy,
×
NEW
384
        })}
×
NEW
385
        <FormActions
×
NEW
386
          onCancel={() => soloSessionTakeOverModal.close()}
×
NEW
387
          onSubmit={() => soloSessionTakeOverModal.close()}
×
NEW
388
          submitButton={{
×
NEW
389
            text: t('modal.confirmButton.text'),
×
NEW
390
            title: t('modal.confirmButton.text'),
×
NEW
391
          }}
×
NEW
392
        />
×
NEW
393
      </Modal>
×
394

395
      <ArticleEditorMenu
×
396
        articleInfos={articleInfos}
×
397
        compareTo={compareTo}
×
398
        selectedVersion={currentVersion}
×
399
        readOnly={mode === MODES_READONLY}
×
400
      />
×
401
      <article className={clsx({ [styles.article]: mode !== MODES_PREVIEW })}>
×
402
        <WorkingVersion
×
403
          articleInfos={articleInfos}
×
404
          live={live}
×
405
          selectedVersion={currentVersion}
×
406
          mode={mode}
×
407
        />
×
408

409
        <Switch>
×
410
          <Route path="*/preview" exact>
×
411
            <PreviewComponent
×
412
              preview={articleInfos.preview}
×
413
              metadata={live.metadata}
×
414
            />
×
415
          </Route>
×
416
          <Route path="*">
×
417
            <MonacoEditor
×
418
              text={live.md}
×
419
              readOnly={mode === MODES_READONLY}
×
420
              onTextUpdate={handleMDCM}
×
421
              articleId={articleInfos._id}
×
422
              selectedVersion={currentVersion}
×
423
              compareTo={compareTo}
×
424
              currentArticleVersion={live.version}
×
425
            />
×
426

427
            <ArticleStats />
×
428
          </Route>
×
429
        </Switch>
×
430
      </article>
×
431
      <ArticleEditorMetadata
×
432
        metadata={live.metadata}
×
433
        onChange={handleMetadataChange}
×
434
        readOnly={mode === MODES_READONLY}
×
435
      />
×
436
    </section>
×
437
  )
438
}
×
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