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

EcrituresNumeriques / stylo / 13906025440

17 Mar 2025 05:32PM UTC coverage: 31.197% (+0.5%) from 30.662%
13906025440

push

github

web-flow
chore: supprime useMutation et clarifie les fonctions (#1304)

* chore: supprime useMutation et clarifie les fonctions

- Renomme useGraphQL en useFetchData
- Introduit une fonction useMutateData pour mettre à jour les données pour un scope (query + variables)

* fix: articleId destructuring

* chore: ajout de tests sur le hook article

* fix: mise à jour des tags associés à l'article sur la liste des articles

* chore: adapt TagEditForm tests

462 of 677 branches covered (68.24%)

Branch coverage included in aggregate %.

165 of 341 new or added lines in 29 files covered. (48.39%)

9 existing lines in 8 files now uncovered.

4367 of 14802 relevant lines covered (29.5%)

2.21 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 { applicationConfig } from '../../config.js'
×
17
import ArticleStats from '../ArticleStats.jsx'
×
18
import ErrorMessageCard from '../ErrorMessageCard.jsx'
×
19

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

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

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

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

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

50
  return MODES_WRITE
×
51
}
×
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

92
  const {
×
UNCOV
93
    visible: collaborativeSessionActiveVisible,
×
94
    setVisible: setCollaborativeSessionActiveVisible,
×
95
    bindings: collaborativeSessionActiveBinding,
×
96
  } = useModal()
×
97

98
  const {
×
99
    visible: soloSessionActiveVisible,
×
100
    setVisible: setSoloSessionActiveVisible,
×
101
    bindings: soloSessionActiveBinding,
×
102
  } = useModal()
×
103

104
  const {
×
105
    visible: soloSessionTakeOverModalVisible,
×
106
    setVisible: setSoloSessionTakeOverModalVisible,
×
107
    bindings: soloSessionTakeOverModalBinding,
×
108
  } = useModal()
×
109

110
  const PreviewComponent = useMemo(
×
111
    () => (articleInfos.preview.stylesheet ? PreviewPaged : PreviewHtml),
×
112
    [articleInfos.preview.stylesheet, currentVersion]
×
113
  )
×
114

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

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

171
  const handleMetadataChange = (metadata) => {
×
172
    updateWorkingArticleMetadata({ metadata })
×
173
    setWorkingArticleDirty()
×
174
    return setLive({ ...live, metadata })
×
175
  }
×
176

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

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

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

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

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

280
        const { md, bib, metadata } = currentArticle
×
281

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

299
      setIsLoading(false)
×
300
    })()
×
301

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

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

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

351
  if (isLoading) {
×
352
    return <Loading />
×
353
  }
×
354

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

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

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

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

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

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

453
Write.propTypes = {
×
454
  version: PropTypes.string,
×
455
  id: PropTypes.string,
×
456
  compareTo: PropTypes.string,
×
457
}
×
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