Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
21 changes: 11 additions & 10 deletions frontend/scenarios/co-edit.feature
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,23 @@
Fonctionnalité: Coéditer un document avec un collaborateur

Contexte:
Soit un document dont je suis l'auteur affiché comme glose
Soit un document, en plusieurs passages, affiché comme glose et dont je suis l'auteur
Et une session active avec mon compte
Et "Bill" un des éditeurs de la glose
Et "bill" un des éditeurs de la glose

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

Alors le passage "1" de la glose contient "Notre sujet porte sur..."
Et le passage "2" est toujours en mode édition

Scénario: qui modifie les métadonnées
Quand "Bill" remplace les métadonnées de la glose par :
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"

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 passage {rubric || '0'}</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;
}
24 changes: 23 additions & 1 deletion frontend/tests/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,29 @@ Soit("un autre document, en plusieurs passages, affiché comme glose et dont je
cy.sign_out();
});

Soit("un document, en plusieurs passages, affiché comme glose et dont je suis l'auteur", () => {
cy.sign_in('alice', '/');
cy.create_document_from_scratch();
const longText = `Première partie :
Ceci est un texte d'exemple suffisamment long pour constituer le premier passage. Il contient plusieurs phrases et même une seconde phrase pour la robustesse.

Deuxième partie :
Voici le second passage qui contient aussi plusieurs phrases et permettra au découpage de produire au moins deux passages distincts.`;
cy.edit_content(longText);
cy.sign_out();
cy.sign_in('alice');
cy.click_on_contextual_menu_item('.runningHead .scholium', 'Break into numbered passages');
cy.sign_out();
});

Soit("le passage {string} est en mode édition", (passage) => {
cy.get('.lectern ')
.children()
.eq(passage)
.find('.scholium')
.click();
});

Soit("un document dont je suis l'auteur affiché comme glose et contenant :", (text) => {
cy.sign_in('alice', '/');
cy.create_document_from_scratch();
Expand Down Expand Up @@ -227,4 +250,3 @@ Soit("je modifie le document", () => {
Soit("{string} le nom de la licence du document principal", (license) => {
cy.get('.license').eq(0).should('contain', license);
});

4 changes: 4 additions & 0 deletions frontend/tests/event.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ Quand("{string} remplace le contenu de la glose par :", (username, text) => {
cy.request_by_user(username, {text});
});

Quand("{string} remplace le passage {string} de la glose par :", (username, frag_num, text) => {
cy.request_fragment_by_user(username, frag_num, {text});
});

Quand("{string} remplace les métadonnées de la glose par :", (username, metadata) => {
cy.request_by_user(username, parseStrToObject(metadata));
});
Expand Down
16 changes: 16 additions & 0 deletions frontend/tests/outcome.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,22 @@ Alors("la glose contient :", (text) => {
cy.contains('.scholium .formatted-text', text.replaceAll('\n', ' ')).should('exist');
});

Alors("le passage {string} de la glose contient {string}", (frag_num, text) => {
cy.get('.lectern')
.children()
.eq(frag_num)
.find('.scholium .formatted-text')
.should('contain', text.replaceAll('\n', ' '));
});

Alors("le passage {string} est toujours en mode édition", (frag_num) => {
cy.get('.lectern')
.children()
.eq(frag_num)
.find('.scholium')
.should('have.descendants', 'form');
});

Alors("la glose est ouverte en mode édition", () => {
cy.get('.scholium').should('have.descendants', 'form');
});
Expand Down
Loading
Loading