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

EcrituresNumeriques / stylo / 12925854411

23 Jan 2025 09:11AM UTC coverage: 25.831% (-4.7%) from 30.523%
12925854411

push

github

web-flow
Merge pull request #1192 from EcrituresNumeriques/feat/vite6

322 of 518 branches covered (62.16%)

Branch coverage included in aggregate %.

3448 of 14077 relevant lines covered (24.49%)

1.66 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'
×
24
import { useGraphQL } from '../../helpers/graphQL'
×
25
import { getEditableArticle as query, stopSoloSession } from './Write.graphql'
×
26

27
import ArticleEditorMenu from './ArticleEditorMenu.jsx'
×
28
import ArticleEditorMetadata from './ArticleEditorMetadata.jsx'
×
29
import WorkingVersion from './WorkingVersion'
×
30
import PreviewHtml from './PreviewHtml'
×
31
import PreviewPaged from './PreviewPaged'
×
32
import Loading from '../Loading'
×
33
import MonacoEditor from './providers/monaco/Editor'
×
34
import clsx from 'clsx'
×
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
export default function Write() {
×
51
  const { setToast } = useToasts()
×
52
  const { backendEndpoint } = applicationConfig
×
53
  const { t } = useTranslation()
×
54
  const { version: currentVersion, id: articleId, compareTo } = useParams()
×
55
  const workingArticle = useSelector(
×
56
    (state) => state.workingArticle,
×
57
    shallowEqual
×
58
  )
×
59
  const userId = useActiveUserId()
×
60
  const dispatch = useDispatch()
×
61
  const runQuery = useGraphQL()
×
62
  const routeMatch = useRouteMatch()
×
63
  const [collaborativeSessionActive, setCollaborativeSessionActive] =
×
64
    useState(false)
×
65
  const [soloSessionActive, setSoloSessionActive] = useState(false)
×
66
  const mode = useMemo(() => {
×
67
    if (collaborativeSessionActive || soloSessionActive) {
×
68
      return MODES_READONLY
×
69
    }
×
70
    return deriveModeFrom({ currentVersion, path: routeMatch.path })
×
71
  }, [
×
72
    currentVersion,
×
73
    routeMatch.path,
×
74
    collaborativeSessionActive,
×
75
    soloSessionActive,
×
76
  ])
×
77
  const [graphQLError, setGraphQLError] = useState()
×
78
  const [isLoading, setIsLoading] = useState(true)
×
79
  const [live, setLive] = useState({})
×
80
  const [soloSessionTakenOverBy, setSoloSessionTakenOverBy] = useState('')
×
81
  const [articleInfos, setArticleInfos] = useState({
×
82
    title: '',
×
83
    owner: '',
×
84
    contributors: [],
×
85
    zoteroLink: '',
×
86
    preview: {},
×
87
  })
×
88

89
  const mutation = useMutation()
×
90

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

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

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

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

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

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

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

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

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

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

226
    setIsLoading(true)
×
227
    ;(async () => {
×
228
      const data = await runQuery({ query, variables }).catch((error) => {
×
229
        setGraphQLError(error)
×
230
        return {}
×
231
      })
×
232

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

276
        const { md, bib, metadata } = currentArticle
×
277

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

295
      setIsLoading(false)
×
296
    })()
×
297

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

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

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

347
  if (isLoading) {
×
348
    return <Loading />
×
349
  }
×
350

351
  return (
×
352
    <section className={styles.container}>
×
353
      <GeistModal
×
354
        width="40rem"
×
355
        visible={collaborativeSessionActiveVisible}
×
356
        {...collaborativeSessionActiveBinding}
×
357
      >
358
        <h2>{t('article.collaborativeSessionActive.title')}</h2>
×
359
        <GeistModal.Content>
×
360
          {t('article.collaborativeSessionActive.message')}
×
361
        </GeistModal.Content>
×
362
        <GeistModal.Action
×
363
          onClick={() => setCollaborativeSessionActiveVisible(false)}
×
364
        >
365
          {t('modal.confirmButton.text')}
×
366
        </GeistModal.Action>
×
367
      </GeistModal>
×
368

369
      <GeistModal
×
370
        width="40rem"
×
371
        visible={soloSessionActiveVisible}
×
372
        {...soloSessionActiveBinding}
×
373
      >
374
        <h2>{t('article.soloSessionActive.title')}</h2>
×
375
        <GeistModal.Content>
×
376
          {t('article.soloSessionActive.message')}
×
377
        </GeistModal.Content>
×
378
        <GeistModal.Action onClick={() => setSoloSessionActiveVisible(false)}>
×
379
          {t('modal.confirmButton.text')}
×
380
        </GeistModal.Action>
×
381
      </GeistModal>
×
382

383
      <GeistModal
×
384
        width="40rem"
×
385
        visible={soloSessionTakeOverModalVisible}
×
386
        {...soloSessionTakeOverModalBinding}
×
387
      >
388
        <h2>{t('article.soloSessionTakeOver.title')}</h2>
×
389
        <GeistModal.Content>
×
390
          {t('article.soloSessionTakeOver.message', {
×
391
            username: soloSessionTakenOverBy,
×
392
          })}
×
393
        </GeistModal.Content>
×
394
        <GeistModal.Action
×
395
          onClick={() => setSoloSessionTakeOverModalVisible(false)}
×
396
        >
397
          {t('modal.confirmButton.text')}
×
398
        </GeistModal.Action>
×
399
      </GeistModal>
×
400

401
      <ArticleEditorMenu
×
402
        articleInfos={articleInfos}
×
403
        compareTo={compareTo}
×
404
        selectedVersion={currentVersion}
×
405
        readOnly={mode === MODES_READONLY}
×
406
      />
×
407
      <article className={clsx({ [styles.article]: mode !== MODES_PREVIEW })}>
×
408
        <WorkingVersion
×
409
          articleInfos={articleInfos}
×
410
          live={live}
×
411
          selectedVersion={currentVersion}
×
412
          mode={mode}
×
413
        />
×
414

415
        <Switch>
×
416
          <Route path="*/preview" exact>
×
417
            <PreviewComponent
×
418
              preview={articleInfos.preview}
×
419
              metadata={live.metadata}
×
420
            />
×
421
          </Route>
×
422
          <Route path="*">
×
423
            <MonacoEditor
×
424
              text={live.md}
×
425
              readOnly={mode === MODES_READONLY}
×
426
              onTextUpdate={handleMDCM}
×
427
              articleId={articleInfos._id}
×
428
              selectedVersion={currentVersion}
×
429
              compareTo={compareTo}
×
430
              currentArticleVersion={live.version}
×
431
            />
×
432

433
            <ArticleStats />
×
434
          </Route>
×
435
        </Switch>
×
436
      </article>
×
437
      <ArticleEditorMetadata
×
438
        metadata={live.metadata}
×
439
        onChange={handleMetadataChange}
×
440
        readOnly={mode === MODES_READONLY}
×
441
      />
×
442
    </section>
×
443
  )
444
}
×
445

446
Write.propTypes = {
×
447
  version: PropTypes.string,
×
448
  id: PropTypes.string,
×
449
  compareTo: PropTypes.string,
×
450
}
×
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