From ead842aa89973695f39225bde180448bec40a2d7 Mon Sep 17 00:00:00 2001 From: Guillaume Grossetie Date: Fri, 29 Sep 2023 12:45:26 +0200 Subject: [PATCH] =?UTF-8?q?Prise=20de=20contr=C3=B4le=20et=20cr=C3=A9ation?= =?UTF-8?q?=20de=20versions=20automatiques?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- front/src/components/Article.graphql | 2 + front/src/components/ArticleVersionLinks.jsx | 32 +++- front/src/components/Articles.graphql | 2 + front/src/components/Articles.jsx | 2 +- front/src/components/Write/Versions.jsx | 108 ++++++-------- front/src/components/Write/WorkingVersion.jsx | 6 +- front/src/components/Write/Write.graphql | 1 + front/src/components/Write/Write.jsx | 50 +++++++ .../Write/articleEditorMenu.module.scss | 4 +- .../Write/createVersion.module.scss | 4 +- front/src/components/Write/menu.module.scss | 1 - .../src/components/Write/versions.module.scss | 75 ++++++++-- front/src/components/articles.module.scss | 7 + front/src/components/solo/SoloSession.graphql | 9 ++ .../src/components/solo/SoloSessionAction.jsx | 47 ++++-- front/src/components/tag/TagsList.jsx | 2 +- front/src/locales/en/translation.json | 8 +- front/src/locales/fr/translation.json | 8 +- front/src/services/ArticleService.graphql | 1 + graphql/data/defaultsData.js | 1 - graphql/events.js | 3 +- graphql/models/article.js | 4 + graphql/models/version.js | 40 ++--- graphql/package-lock.json | 14 -- graphql/package.json | 1 - graphql/resolvers/articleResolver.js | 141 ++++++++++++------ graphql/schema.js | 3 + schema.graphql | 3 + 28 files changed, 396 insertions(+), 183 deletions(-) diff --git a/front/src/components/Article.graphql b/front/src/components/Article.graphql index 712ee1632..64c774424 100644 --- a/front/src/components/Article.graphql +++ b/front/src/components/Article.graphql @@ -17,6 +17,8 @@ query getArticleVersions($articleId: ID!) { message revision version + type + createdAt } } } diff --git a/front/src/components/ArticleVersionLinks.jsx b/front/src/components/ArticleVersionLinks.jsx index cabe2b8ee..751675e62 100644 --- a/front/src/components/ArticleVersionLinks.jsx +++ b/front/src/components/ArticleVersionLinks.jsx @@ -1,4 +1,5 @@ import { Loading } from '@geist-ui/core' +import clsx from 'clsx' import React, { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' @@ -6,6 +7,7 @@ import useGraphQL from '../hooks/graphql.js' import styles from './articles.module.scss' import { getArticleVersions } from './Article.graphql' +import TimeAgo from './TimeAgo.jsx' export default function ArticleVersionLinks ({ articleId, article }) { const { t } = useTranslation() @@ -19,7 +21,22 @@ export default function ArticleVersionLinks ({ articleId, article }) { revalidateOnFocus: false, revalidateOnReconnect: false }) - const versions = useMemo(() => data?.article?.versions || [], [data]) + const getVersions = () => (data?.article?.versions || []).map(v => { + let title = '' + if (v.type === 'editingSessionEnded') { + title = t('version.editingSessionEnded.text') + } else if (v.type === 'collaborativeSessionEnded') { + title = t('version.collaborativeSessionEnded.text') + } else { + title = `v${v.version}.${v.revision} ${v.message}` + } + return { + ...v, + type: v.type || 'userAction', + title, + } + }) + const versions = useMemo(getVersions, [data]) if (isLoading) { return @@ -32,15 +49,16 @@ export default function ArticleVersionLinks ({ articleId, article }) {

{t('article.versions.title')}

} - ) +) } diff --git a/front/src/components/Articles.graphql b/front/src/components/Articles.graphql index b9fa46dad..07121047b 100644 --- a/front/src/components/Articles.graphql +++ b/front/src/components/Articles.graphql @@ -66,6 +66,7 @@ query getUserArticles($user: ID!) { creator { _id } + creatorUsername createdAt } } @@ -128,6 +129,7 @@ query getWorkspaceArticles ($workspaceId: ID!) { creator { _id } + creatorUsername createdAt } diff --git a/front/src/components/Articles.jsx b/front/src/components/Articles.jsx index d6c8acdbb..89d645964 100644 --- a/front/src/components/Articles.jsx +++ b/front/src/components/Articles.jsx @@ -123,7 +123,7 @@ export default function Articles () { useEffect(() => { let events if (!isLoading) { - events = new EventSource(`${backendEndpoint}/events`) + events = new EventSource(`${backendEndpoint}/events?userId=${activeUserId}`) events.onmessage = (event) => { handleStateUpdated(event) } diff --git a/front/src/components/Write/Versions.jsx b/front/src/components/Write/Versions.jsx index a63e3b8b7..f321c315a 100644 --- a/front/src/components/Write/Versions.jsx +++ b/front/src/components/Write/Versions.jsx @@ -1,5 +1,6 @@ import React, { useCallback, useState } from 'react' import PropTypes from 'prop-types' +import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' import { shallowEqual, useDispatch, useSelector } from 'react-redux' import { ArrowLeft, Check, ChevronDown, ChevronRight, Edit3 } from 'react-feather' @@ -19,7 +20,8 @@ import Field from '../Field' import CreateVersion from './CreateVersion' import clsx from 'clsx' -function Version ({ articleId, articleName: name, compareTo, onExport, readOnly, selectedVersion, v }) { +function Version ({ articleId, compareTo, readOnly, selectedVersion, v }) { + const { t } = useTranslation() const className = clsx({ [styles.selected]: v._id === selectedVersion, [styles.compareTo]: v._id === compareTo @@ -27,12 +29,13 @@ function Version ({ articleId, articleName: name, compareTo, onExport, readOnly, const articleVersionId = v._id const versionPart = selectedVersion ? `version/${selectedVersion}/` : '' - const compareLink = `/article/${articleId}/${versionPart}compare/${v._id}` + const compareLink = `/article/${articleId}/${versionPart}compare/${v._id}` const runQuery = useGraphQL() const [renaming, setRenaming] = useState(false) const [title, setTitle] = useState(v.message) - const setExportParams = useCallback(() => onExport({ articleId, articleVersionId, bib: v.bibPreview, name }), []) + const versionType = v.type || 'userAction' + const manualVersion = versionType === 'userAction' const startRenaming = useCallback((event) => event.preventDefault() || setRenaming(true), []) const cancelRenaming = useCallback(() => setTitle(v.message) || setRenaming(false), []) @@ -44,24 +47,15 @@ function Version ({ articleId, articleName: name, compareTo, onExport, readOnly, setRenaming(false) }, [title]) - return
  • - {!renaming &&
    - - {title || 'no label'} - v{v.version}.{v.revision} - - - {!readOnly && } -
    } - + return
  • {renaming && (
    - setTitle(event.target.value)} /> + setTitle(event.target.value)} + placeholder={'Label of the version'}/>
    )} - - {!renaming &&

    - {v.owner && ( - + + {!renaming && versionType === 'editingSessionEnded' &&

    + {t('version.editingSessionEnded.text')} +
    } + + {!renaming && versionType === 'collaborativeSessionEnded' &&
    + {t('version.collaborativeSessionEnded.text')} +
    } + + {!renaming && versionType === 'userAction' &&
    + + v{v.version}.{v.revision}{' '}{title || ''} + + {!readOnly && } +
    } + + {!renaming &&

    + {v.owner && ( + by {v.owner.displayName || v.owner.username} - )} - - + )} + + -

    } - - {!renaming &&
      +

      } + + {!renaming && selectedVersion &&
        {![compareTo, selectedVersion].includes(v._id) && (
      • Compare @@ -95,40 +106,23 @@ function Version ({ articleId, articleName: name, compareTo, onExport, readOnly, {v._id === compareTo && (
      • Stop
      • )} -
      • - - Preview - -
      • -
      • - -
      } } Version.propTypes = { articleId: PropTypes.string.isRequired, - articleName: PropTypes.string.isRequired, v: PropTypes.object.isRequired, selectedVersion: PropTypes.string, compareTo: PropTypes.string, readOnly: PropTypes.bool, - onExport: PropTypes.func.isRequired } export default function Versions ({ article, selectedVersion, compareTo, readOnly }) { @@ -145,7 +139,6 @@ export default function Versions ({ article, selectedVersion, compareTo, readOnl dispatch({ type: 'ARTICLE_PREFERENCES_TOGGLE', key: 'expandVersions', value: false }) setExpandCreateForm(true) }, []) - const handleVersionExport = useCallback(({ articleId, articleVersionId, bib, name }) => setExportParams({ articleId, articleVersionId, bib, name }), []) const cancelExport = useCallback(() => setExportParams({}), []) return ( @@ -154,9 +147,10 @@ export default function Versions ({ article, selectedVersion, compareTo, readOnl {expand ? : } Versions - + } + {readOnly && Edit Mode} {exportParams.articleId && ( @@ -165,25 +159,21 @@ export default function Versions ({ article, selectedVersion, compareTo, readOnl )} {expand && ( <> - {expandCreateForm && } + {expandCreateForm && } {articleVersions.length === 0 && (

      All changes are automatically saved.
      Create a new version to keep track of particular changes.

      )} - {readOnly && Edit Mode} -
        {articleVersions.map((v) => ( + articleId={article._id} + selectedVersion={selectedVersion} + compareTo={compareTo} + readOnly={readOnly} + v={v}/> ))}
      diff --git a/front/src/components/Write/WorkingVersion.jsx b/front/src/components/Write/WorkingVersion.jsx index b8fb2b4b7..d2d8912fa 100644 --- a/front/src/components/Write/WorkingVersion.jsx +++ b/front/src/components/Write/WorkingVersion.jsx @@ -77,6 +77,10 @@ export default function WorkingVersion ({ articleInfos, live, selectedVersion, m const cancelExport = useCallback(() => setExporting(false), []) const openExport = useCallback(() => setExporting(true), []) + + const previewUrl = selectedVersion + ? `/article/${articleInfos._id}/version/${selectedVersion}/preview` + : `/article/${articleInfos._id}/preview` const articleOwnerAndContributors = [ articleInfos.owner.displayName, ...articleInfos.contributors.map(contributor => contributor.user.displayName) @@ -114,7 +118,7 @@ export default function WorkingVersion ({ articleInfos, live, selectedVersion, m
    • - diff --git a/front/src/components/Write/Write.graphql b/front/src/components/Write/Write.graphql index fb1414cec..685c3c1d6 100644 --- a/front/src/components/Write/Write.graphql +++ b/front/src/components/Write/Write.graphql @@ -48,6 +48,7 @@ query getEditableArticle ($article: ID!, $hasVersion: Boolean!, $version: ID!) { displayName username } + type } workingVersion @skip(if: $hasVersion) { diff --git a/front/src/components/Write/Write.jsx b/front/src/components/Write/Write.jsx index 4e48357df..5a524c9bb 100644 --- a/front/src/components/Write/Write.jsx +++ b/front/src/components/Write/Write.jsx @@ -43,6 +43,7 @@ export function deriveModeFrom ({ path, currentVersion }) { export default function Write() { const { setToast } = useToasts() + const backendEndpoint = useSelector(state => state.applicationConfig.backendEndpoint) const { t } = useTranslation() const { version: currentVersion, id: articleId, compareTo } = useParams() const workingArticle = useSelector(state => state.workingArticle, shallowEqual) @@ -61,6 +62,7 @@ export default function Write() { const [graphQLError, setGraphQLError] = useState() const [isLoading, setIsLoading] = useState(true) const [live, setLive] = useState({}) + const [soloSessionTakenOverBy, setSoloSessionTakenOverBy] = useState('') const [articleInfos, setArticleInfos] = useState({ title: '', owner: '', @@ -83,6 +85,12 @@ export default function Write() { bindings: soloSessionActiveBinding } = useModal() + const { + visible: soloSessionTakeOverModalVisible, + setVisible: setSoloSessionTakeOverModalVisible, + bindings: soloSessionTakeOverModalBinding + } = useModal() + const PreviewComponent = useMemo( () => articleInfos.preview.stylesheet ? PreviewPaged : PreviewHtml, [articleInfos.preview.stylesheet, currentVersion] @@ -147,6 +155,25 @@ export default function Write() { return setLive({ ...live, yaml: metadata }) } + const handleStateUpdated = useCallback((event) => { + const parsedData = JSON.parse(event.data) + if (parsedData.articleStateUpdated) { + const articleStateUpdated = parsedData.articleStateUpdated + if (articleId === articleStateUpdated._id) { + if (articleStateUpdated.soloSession && articleStateUpdated.soloSession.id) { + if (userId !== articleStateUpdated.soloSession.creator._id) { + setSoloSessionTakenOverBy(articleStateUpdated.soloSession.creatorUsername) + setSoloSessionActive(true) + setSoloSessionTakeOverModalVisible(true) + } + } else if (articleStateUpdated.collaborativeSession) { + setCollaborativeSessionActiveVisible(true) + setCollaborativeSessionActive(true) + } + } + } + }, [articleId]) + useEffect(() => { // FIXME: should retrieve extensions.type 'COLLABORATIVE_SESSION_CONFLICT' if (workingArticle && workingArticle.state === 'saveFailure' && workingArticle.stateMessage === 'Active collaborative session, cannot update the working copy.') { @@ -245,6 +272,21 @@ export default function Write() { } }, [currentVersion, articleId]) + useEffect(() => { + let events + if (!isLoading) { + events = new EventSource(`${backendEndpoint}/events?userId=${userId}`) + events.onmessage = (event) => { + handleStateUpdated(event) + } + } + return () => { + if (events) { + events.close() + } + } + }, [isLoading, handleStateUpdated]) + if (graphQLError) { return (
      @@ -277,6 +319,14 @@ export default function Write() { setSoloSessionActiveVisible(false)}>{t('modal.confirmButton.text')} + +

      {t('article.soloSessionTakeOver.title')}

      + + {t('article.soloSessionTakeOver.message', { username: soloSessionTakenOverBy })} + + setSoloSessionTakeOverModalVisible(false)}>{t('modal.confirmButton.text')} +
      + li:not(:first-child) { - margin-left: 0.5em; + margin-left: 0.25em; } } diff --git a/front/src/components/Write/menu.module.scss b/front/src/components/Write/menu.module.scss index c29e7fbcd..ad56564fe 100644 --- a/front/src/components/Write/menu.module.scss +++ b/front/src/components/Write/menu.module.scss @@ -5,7 +5,6 @@ > h1 { font-size: 1.2rem; @extend .clickable; - border-top: 1px solid $main-border-color; padding: 0.5rem; display: flex; diff --git a/front/src/components/Write/versions.module.scss b/front/src/components/Write/versions.module.scss index 82d97daa8..d103a067b 100644 --- a/front/src/components/Write/versions.module.scss +++ b/front/src/components/Write/versions.module.scss @@ -4,12 +4,10 @@ .versionsList { font-size: 0.9em; + padding-bottom: 1em; > li { padding: .5rem; } - > li:nth-child(2n + 1) { - background-color: $main-background-color; - } > li.selected { background-color: $selected-background; } @@ -24,30 +22,78 @@ .editTitleButton { @extend article, .editTitleButton; height: auto; - padding: .4em .25em; + padding: 0 .25em; } .momentAgo { @extend article, .momentsAgo; } -.versionNumber { - margin-left: .25em; - &::after { - content: ")"; - } - &::before { - content: "("; +.version { + border-left: 4px solid #cecece; + margin-bottom: 0.25em; + margin-left: 0.5rem; + margin-right: 0.25em; + cursor: pointer; + display: flex; +} + +.versionLink { + color: inherit; + text-decoration: none; + flex-grow: 1; + + > header { + max-width: 265px; + white-space: nowrap; + display: flex; } } +.versionLinkCompare { + max-width: 200px; +} + +.manualVersion { + border-left: 4px solid #202020; +} + +.automaticVersion { + border-left: 4px solid #bebebe; + color: #858585; +} + +.versionLabel { + text-overflow: ellipsis; + display: block; + overflow: hidden; + padding-bottom: 0.15em; +} + +.versionNumber { +} + +.automaticVersion > header { + font-style: italic; +} + .actions { display: flex; gap: 0.5em; - margin-top: 0.5rem; + margin-top: 0.15rem; margin-bottom: 0.25rem; justify-content: flex-end; - font-size: 0.9em; + font-size: 0.75em; +} + +.action { + background-color: transparent; + border: none; + text-decoration: underline; +} + +.action:hover { + text-decoration: underline; } .headingAction { @@ -56,6 +102,5 @@ .editMode { font-size: 0.8rem; - margin-top: 0.75em; - margin-left: 0.5em; + margin-left: auto; } diff --git a/front/src/components/articles.module.scss b/front/src/components/articles.module.scss index 8d25b6b46..66123ce59 100644 --- a/front/src/components/articles.module.scss +++ b/front/src/components/articles.module.scss @@ -468,3 +468,10 @@ hr.horizontalSeparator { display: flex; gap: 0.35em; } + +.userVersion { + font-weight: 500; +} +.automaticVersion { + font-weight: 300; +} diff --git a/front/src/components/solo/SoloSession.graphql b/front/src/components/solo/SoloSession.graphql index 2aeb769d8..de92a3b8c 100644 --- a/front/src/components/solo/SoloSession.graphql +++ b/front/src/components/solo/SoloSession.graphql @@ -5,6 +5,7 @@ query getSoloSession($articleId: ID!) { creator { _id } + creatorUsername createdAt } } @@ -17,3 +18,11 @@ mutation startSoloSession($articleId: ID!) { } } } + +mutation takeOverSoloSession($articleId: ID!) { + article (articleId: $articleId) { + takeOverSoloSession { + id + } + } +} diff --git a/front/src/components/solo/SoloSessionAction.jsx b/front/src/components/solo/SoloSessionAction.jsx index 9c562fe18..b37372872 100644 --- a/front/src/components/solo/SoloSessionAction.jsx +++ b/front/src/components/solo/SoloSessionAction.jsx @@ -1,23 +1,30 @@ -import { Dot, useToasts } from '@geist-ui/core' +import { Dot, Modal as GeistModal, useModal, useToasts } from '@geist-ui/core' import PropTypes from 'prop-types' -import React, { useCallback } from 'react' +import React, { useCallback, useState } from 'react' import { Edit3 } from 'react-feather' +import { useTranslation } from 'react-i18next' import { useHistory } from 'react-router-dom' import { useMutation } from '../../hooks/graphql.js' import Button from '../Button.jsx' -import { startSoloSession } from './SoloSession.graphql' +import { startSoloSession, takeOverSoloSession } from './SoloSession.graphql' export default function SoloSessionAction ({ collaborativeSession, soloSession, articleId }) { + const { t } = useTranslation() const { setToast } = useToasts() const history = useHistory() + const [activeSoloSessionCreator, setActiveSoloSessionCreator] = useState('') const mutation = useMutation() - + const { + visible: takeOverModalVisible, + setVisible: setTakeOverModalVisible, + bindings: takeOverModalBinding + } = useModal() const handleStartSoloEditing = useCallback(async () => { - // try to start a collaborative editing + console.log({soloSession}) if (soloSession && soloSession.id) { - // join existing solo session - history.push(`/article/${articleId}`) + setActiveSoloSessionCreator(soloSession.creatorUsername) + setTakeOverModalVisible(true) } else { // start a new solo session try { @@ -36,7 +43,20 @@ export default function SoloSessionAction ({ collaborativeSession, soloSession, } } } - }, [soloSession]) + }, [soloSession, setTakeOverModalVisible]) + const handleTakeOver = useCallback(async () => { + try { + await mutation({ query: takeOverSoloSession, variables: { articleId } }) + setTakeOverModalVisible(false) + history.push(`/article/${articleId}`) + } catch (err) { + setToast({ + type: 'error', + text: `Unable to take over this solo session: ${err.toString()}` + } + ) + } + }, [setTakeOverModalVisible]) if (collaborativeSession) { return <> @@ -44,6 +64,14 @@ export default function SoloSessionAction ({ collaborativeSession, soloSession, return ( <> + +

      Take over

      + + Would you like to take over the editing session from {activeSoloSessionCreator}? + + setTakeOverModalVisible(false)}>{t('modal.cancelButton.text')} + handleTakeOver()}>{t('modal.confirmButton.text')} +