Skip to content
Draft
10 changes: 10 additions & 0 deletions backend/hyperglosae/src/updates/editFlag.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
function(doc, req) {
if (!doc) return [null, { json: { error: 'not_found' } }];
const body = JSON.parse(req.body);
if (body.beingEditedBy) {
doc.beingEditedBy = body.beingEditedBy;
} else {
delete doc.beingEditedBy;
}
return [doc, { json: { status: 'ok' } }];
}
6 changes: 3 additions & 3 deletions backend/hyperglosae/src/views/content/map.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
function (doc) {
const { getRelatedDocuments, emitPassages, emitIncludedDocuments } = require('views/lib/links');

let { _id, text = '', isPartOf = _id, links = [] } = doc;
let { _id, text = '', isPartOf = _id, links = [], beingEditedBy } = doc;
let related = getRelatedDocuments({isPartOf, links});

emitPassages({text, isPartOf, related});
emitPassages({text, isPartOf, related, beingEditedBy});
emitIncludedDocuments({isPartOf, links});
}
}
12 changes: 9 additions & 3 deletions backend/hyperglosae/src/views/lib/links.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,17 @@ const parseText = (text) => {
}));
}

exports.emitPassages = ({text, isPartOf, related}) => {
exports.emitPassages = ({text, isPartOf, related, beingEditedBy}) => {
parseText(text).forEach(({rubric, passage, parsed_rubric}) =>
related.forEach((x) => {
emit([x, ...parsed_rubric], { text: passage, isPartOf, rubric, _id: null });
})
emit([x, ...parsed_rubric], {
text: passage,
isPartOf,
rubric,
_id: null,
...(beingEditedBy && {beingEditedBy})
});
})
);
}

Expand Down
19 changes: 6 additions & 13 deletions frontend/scenarios/co-edit.feature
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,11 @@ Contexte:
Et une session active avec mon compte
Et "Bill" un des éditeurs de la glose

Scénario: qui modifie le contenu
Quand "Bill" remplace le contenu de la glose par :
"""
Notre sujet porte sur...
"""
Alors la glose en mode édition contient "Notre sujet"


Scénario: qui modifie les métadonnées
Quand "Bill" remplace les métadonnées de la glose par :
"""
dc_creator: Bill

"""
Alors les métadonnées de la glose en mode édition contiennent "dc_creator: Bill"


Scénario: qui est en train de modifier le contenu
Quand "Bill" est en train d’éditer le passage "1"
Alors la glose en mode édition indique que "Bill" modifie le passage

44 changes: 38 additions & 6 deletions frontend/src/components/EditableText.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import DiscreeteDropdown from './DiscreeteDropdown';
import PictureUploadAction from '../menu-items/PictureUploadAction';
import {v4 as uuid} from 'uuid';
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
import { PencilSquare } from 'react-bootstrap-icons';

function EditableText({id, text, rubric, isPartOf, links, fragment, setFragment, setHighlightedText, setSelectedText, rawEditMode, setRawEditMode, backend, setLastUpdate}) {
function EditableText({id, text, rubric, isPartOf, links, beingEditedBy, fragment, setFragment, setHighlightedText, setSelectedText, rawEditMode, setRawEditMode, backend, setLastUpdate, user}) {
const [beingEdited, setBeingEdited] = useState(false);
const [editedDocument, setEditedDocument] = useState();
const [editedText, setEditedText] = useState();
const [hasBeenChanged, setHasBeenChanged] = useState(false);
const PASSAGE = new RegExp(`\\{${rubric}} ?([^{]*)`);
const isEditedByOther = beingEditedBy && beingEditedBy !== user;

let parsePassage = (rawText) => (rubric)
? rawText.match(PASSAGE)[1]
Expand All @@ -33,6 +35,19 @@ function EditableText({id, text, rubric, isPartOf, links, fragment, setFragment,
return x;
}), [backend, id, isPartOf, links, rubric]);

// Marque "en édition par <user>" quand on entre, démarque quand on sort
useEffect(() => {
if (!user || !id) return;
if (beingEdited) {
backend.markEditing(id, user).catch(console.error);
}
return () => {
if (beingEdited) {
backend.markEditing(id, null).catch(console.error);
}
};
}, [beingEdited, id, user, backend]);

useEffect(() => {
if (fragment) {
updateEditedDocument()
Expand All @@ -57,6 +72,7 @@ function EditableText({id, text, rubric, isPartOf, links, fragment, setFragment,
}, [rawEditMode, updateEditedDocument]);

let handleClick = () => {
if (isEditedByOther) return;
setBeingEdited(true);
updateEditedDocument()
.then((x) => {
Expand Down Expand Up @@ -102,25 +118,41 @@ function EditableText({id, text, rubric, isPartOf, links, fragment, setFragment,
.catch(console.error);
};

// Vue lecture
if (!beingEdited) return (
<div className="editable content position-relative">
<div className="editable content position-relative" id={id} >
{isEditedByOther && (
<OverlayTrigger
placement="top"
overlay={<Tooltip id={`being-edited-${id}`}>{beingEditedBy} is currently editing this passage</Tooltip>}
>
<PencilSquare className="being-edited-icon" data-testid="being-edited-icon" />
</OverlayTrigger>
)}
<OverlayTrigger
placement="top"
overlay={<Tooltip id={`tooltip-${id}`}>Edit content...</Tooltip>}
overlay={
<Tooltip id={`tooltip-${id}`}>
{isEditedByOther ? `Locked by ${beingEditedBy}` : 'Edit content...'}
</Tooltip>
}
>
<div className="formatted-text" onClick={handleClick}>
<FormattedText {...{setHighlightedText, setSelectedText}}>
{text || '&nbsp;'}
{text || '\u00A0'}
</FormattedText>
</div>
</OverlayTrigger>
<DiscreeteDropdown>
<PictureUploadAction {... {id, backend, handleImageUrl}}/>
<PictureUploadAction {...{id, backend, handleImageUrl}}/>
</DiscreeteDropdown>
</div>
);

// Vue édition
return (
<form>
<form className="position-relative">
<PencilSquare className="being-edited-icon self" data-testid="being-edited-self" />
<textarea className="form-control" type="text" rows="5" autoFocus
value={editedText} onChange={handleChange} onBlur={handleBlur}
/>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/OpenedDocuments.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function OpenedDocuments({id, margin, metadata, parallelDocuments, rawEditMode,
</Row>
{parallelDocuments.passages.map(({rubric, source, scholia}, i) =>
<Passage key={rubric || i}
{...{source, rubric, scholia, margin, sourceId: id, isComposite, rawEditMode, setRawEditMode, backend, setLastUpdate}}
{...{source, rubric, scholia, margin, sourceId: id, isComposite, rawEditMode, setRawEditMode, backend, setLastUpdate, user}}
/>)
}
<Row>
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/components/Passage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import EditableText from '../components/EditableText';
import DiscreeteDropdown from './DiscreeteDropdown';
import CommentFragmentAction from '../menu-items/CommentFragmentAction';

function Passage({source, rubric, scholia, margin, sourceId, isComposite, rawEditMode, setRawEditMode, backend, setLastUpdate}) {
function Passage({source, rubric, scholia, margin, sourceId, isComposite, rawEditMode, setRawEditMode, backend, setLastUpdate, user}) {
const [selectedText, setSelectedText] = useState();
const [highlightedText, setHighlightedText] = useState('');
const [fragment, setFragment] = useState();
Expand Down Expand Up @@ -39,7 +39,7 @@ function Passage({source, rubric, scholia, margin, sourceId, isComposite, rawEdi
</Container>
}
</Col>
<PassageMargin active={!!margin} {...{scholia, rubric, setHighlightedText, fragment, setFragment, setSelectedText, rawEditMode, setRawEditMode, backend, setLastUpdate}} />
<PassageMargin active={!!margin} {...{scholia, rubric, setHighlightedText, fragment, setFragment, setSelectedText, rawEditMode, setRawEditMode, backend, setLastUpdate, user}} />
</Row>
);
}
Expand Down Expand Up @@ -87,12 +87,12 @@ function Rubric({id}) {
);
}

function PassageMargin({active, scholia, setHighlightedText, fragment, setFragment, setSelectedText, rawEditMode, setRawEditMode, backend, setLastUpdate}) {
function PassageMargin({active, scholia, setHighlightedText, fragment, setFragment, setSelectedText, rawEditMode, setRawEditMode, backend, setLastUpdate, user}) {
if (!active) return;
return (
<Col xs={5} className="scholium">
{scholia.map((scholium, i) =>
<EditableText key={i} {...scholium} {...{setHighlightedText, fragment, setFragment, setSelectedText, rawEditMode, setRawEditMode, backend, setLastUpdate}} />
<EditableText key={i} {...scholium} {...{setHighlightedText, fragment, setFragment, setSelectedText, rawEditMode, setRawEditMode, backend, setLastUpdate, user}} />
)}
</Col>
);
Expand Down
13 changes: 11 additions & 2 deletions frontend/src/hyperglosae.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ function Hyperglosae(logger) {
fetch(`${service}/${id}/${attachment.name}`, {
method: 'PUT',
headers: {
// ETag is the header that carries the current rev.
'If-Match': x.headers.get('ETag'),
'Content-Type': attachment.type
},
Expand Down Expand Up @@ -119,7 +118,17 @@ function Hyperglosae(logger) {
this.getView({view: 'all_documents', id: user || 'PUBLIC', options: ['include_docs']})
.then((rows) => rows.map(x => x.doc));

this.markEditing = (id, beingEditedBy) =>
fetch(`${service}/_design/app/_update/editFlag/${id}`, {
method: 'PUT',
body: JSON.stringify({ beingEditedBy })
}).then(x => x.json());

this.subscribeToChanges = (since = 'now') =>
fetch(`${service}/_changes?feed=longpoll&since=${since}&heartbeat=25000`)
.then(x => x.json());

return this;
}

export default Hyperglosae;
export default Hyperglosae;
7 changes: 6 additions & 1 deletion frontend/src/parallelDocuments.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,12 @@ function ParallelDocuments(id, rawContent = [], margin, raw = false) {
if (xor(!this.isFromScratch, isPartOf === id)) {
if (!raw || !part.scholia.length || part.scholia[part.scholia.length - 1].id !== x.id) {
let rubric = x.value.rubric;
part.scholia.push({id: x.id, text, isPartOf, ...(rubric !== '0' && {rubric})});

part.scholia.push({
id: x.id, text, isPartOf,
...(rubric !== '0' && {rubric}),
...(x.value.beingEditedBy && {beingEditedBy: x.value.beingEditedBy})
});
}
}
}
Expand Down
26 changes: 25 additions & 1 deletion frontend/src/routes/Lectern.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,30 @@ function Lectern({backend, user}) {
setParallelDocuments(new ParallelDocuments(id, content, margin, rawEditMode));
}, [id, content, margin, rawEditMode, lastUpdate]);

// Long polling sur les changements CouchDB
useEffect(() => {
let cancelled = false;
let since = 'now';
function poll() {
backend.subscribeToChanges(since)
.then(({ last_seq, results }) => {
if (cancelled) return;
since = last_seq;
if (results && results.length > 0) {
setLastUpdate(last_seq);
}
poll();
})
.catch(() => {
if (!cancelled) setTimeout(poll, 5000);
});
}
poll();
return () => {
cancelled = true;
};
}, [backend]);

if (!metadata?.focusedDocument?._id && !loading) {
return <DocumentNotFound />;
}
Expand Down Expand Up @@ -85,4 +109,4 @@ function References({metadata, active, createOn, setLastUpdate, backend, user})
);
}

export default Lectern;
export default Lectern;
11 changes: 11 additions & 0 deletions frontend/src/styles/EditableText.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,15 @@
border-color: black;
}

.being-edited-icon {
position: absolute;
top: 4px;
right: 4px;
color: #d2691e;
z-index: 5;
pointer-events: auto;
}

.being-edited-icon.self {
color: #2e7d32;
}
15 changes: 15 additions & 0 deletions frontend/tests/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,3 +228,18 @@ Soit("{string} le nom de la licence du document principal", (license) => {
cy.get('.license').eq(0).should('contain', license);
});

Soit("{string} est en train d'éditer le passage {string}", (username, passageNumber) => {
cy.request_by_user(username, { editing: { block_number: passageNumber } });
});

Soit("la glose en mode édition indique que {string} modifie ce passage", (userName) => {
cy.get('.scholium .editable.content')
.find('[data-testid="being-edited-icon"]')
.trigger('mouseover');

cy.contains(
'.tooltip',
`${userName} is currently editing this passage`
).should('be.visible');
});

10 changes: 9 additions & 1 deletion frontend/tests/event.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,18 @@ Quand("j'essaie d'ajouter une image à une glose", () => {
});

Quand("{string} remplace le contenu de la glose par :", (username, text) => {
cy.request_by_user(username, {text});
cy.request_by_user(username, parseStrToObject(text));
});

Quand("{string} remplace les métadonnées de la glose par :", (username, metadata) => {
cy.request_by_user(username, parseStrToObject(metadata));
});

Quand("{string} est en train d’éditer le passage {string}", (username, passageNumber) => {
cy.request_by_user(username,{beingEditedBy: username});
cy.get('.scholium .formatted-text').click();
});

Quand("{string} quitte le mode édition", (username) => {
cy.request_by_user(username, {beingEditedBy: undefined});
});
14 changes: 14 additions & 0 deletions frontend/tests/outcome.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,3 +206,17 @@ Alors("les métadonnées de la glose en mode édition contiennent {string}", (me
cy.editable_metadata_contains(metadata);
});

Alors("la glose en mode édition indique que {string} modifie le passage", (userName) => {
cy.get('.scholium .editable.content svg[data-testid="being-edited-icon"]')
.trigger('mouseover');

cy.contains(
'.tooltip',
`${userName} is currently editing this passage`
).should('be.visible');
});

Alors("la glose n'indique pas de modification en cours sur ce passage", () => {
cy.get('.scholium .editable.content')
.should('not.have.descendants', '[data-testid="being-edited-icon"]');
});
Loading