feat: editor language menu, title field, header actions, publish dialogs, read-only rendering and draft recovery

This commit is contained in:
Florian Sylvain committed 2026-11-02 17:13:14 +01:00
1 parent a671b81f80
commit 339e646eb8
85 files changed
+6369 -624

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { editorMessages } from './editorMessages'
import { messages } from './messages'
const SOURCES = [
'components/EditorActionDialog.vue',
'components/EditorActions.vue',
'components/EditorLanguageMenu.vue',
'components/EditorLanguageRow.vue',
'components/EditorLeaveDialog.vue',
'components/EditorMobileBar.vue',
'components/EditorNotices.vue',
'components/EditorOverflowMenu.vue',
'components/EditorTitleField.vue',
'components/EditorVersionGone.vue',
'composables/useEditorPublishing.ts',
'composables/useVersionSwitch.ts',
'lib/editorLabels.ts',
'lib/editorNotices.ts',
'lib/editorOverflow.ts',
'views/EditorView.vue',
]
const SAME_IN_FRENCH = new Set(['Save', 'Draft', 'Title', 'Body', 'Articles'])
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
const NUMBERED = /\{\d+\}/g
function keysIn(source: string) {
return [...source.matchAll(LITERAL)].map((match) =>
(match[1] ?? match[2] ?? '').replace(/\\(['"\\])/g, '$1'),
)
}
describe('Editor messages', () => {
const keys = SOURCES.flatMap((file) =>
keysIn(readFileSync(join(import.meta.dirname, '..', file), 'utf8')).map((key) => ({
file,
key,
})),
)
it('finds the strings of the editor', () => {
expect(keys.length).toBeGreaterThan(60)
})
it.each(keys.map(({ file, key }) => [file, key]))(
'%s: "%s" has a French entry',
(_file, key) => {
const french = (messages as Record<string, string>)[key as string]
expect(french).toMatch(/\S/)
if (!SAME_IN_FRENCH.has(key as string)) {
expect(french).not.toBe(key)
}
},
)
it.each(Object.entries(editorMessages))(
'keeps the placeholders of "%s" in French',
(english, french) => {
expect([...french.matchAll(NUMBERED)].map((m) => m[0]).sort()).toEqual(
[...english.matchAll(NUMBERED)].map((m) => m[0]).sort(),
)
},
)
})
+91
View File
@@ -0,0 +1,91 @@
export const editorMessages = {
'Language: {0}. Switch version': 'Langue : {0}. Changer de version',
'Versions you can open': 'Versions que vous pouvez ouvrir',
'Find a language': 'Rechercher une langue',
'Switching opens that version. The interface language stays the same.':
'Changer ouvre cette version. La langue de l’interface ne change pas.',
'Versions of this article': 'Versions de cet article',
'Add translation': 'Ajouter une traduction',
"Your changes to the {0} version aren't saved.":
'Vos modifications de la version {0} ne sont pas enregistrées.',
'Discard and open {0}': 'Abandonner et ouvrir {0}',
'Save and open {0}': 'Enregistrer et ouvrir {0}',
'Retry publish': 'Réessayer la publication',
'Enter a title': 'Saisissez un titre',
'Use at least 3 characters': 'Utilisez au moins 3 caractères',
'Use 200 characters or fewer': 'Utilisez 200 caractères au plus',
"Couldn't save the title. Your text is kept.":
'Impossible d’enregistrer le titre. Votre texte est conservé.',
'Saving title…': 'Enregistrement du titre…',
"Changing the title doesn't change the address.": 'Modifier le titre ne modifie pas l’adresse.',
"This version isn't available": 'Cette version n’est pas disponible',
'It may have been deleted, or you may not have access.':
'Elle a peut-être été supprimée, ou vous n’y avez pas accès.',
"{0} isn't affected.": '{0} n’est pas concerné.',
"{0} aren't affected.": '{0} ne sont pas concernés.',
'Publish {0}?': 'Publier {0} ?',
'Only the {0} version goes online.': 'Seule la version {0} est mise en ligne.',
'Save and publish {0}?': 'Enregistrer et publier {0} ?',
'Your unsaved changes to the {0} version are saved first, then only the {0} version goes online.':
'Vos modifications non enregistrées de la version {0} sont d’abord enregistrées, puis seule la version {0} est mise en ligne.',
'Save and publish {0}': 'Enregistrer et publier {0}',
'{0} stays online.': '{0} reste en ligne.',
'{0} stay online.': '{0} restent en ligne.',
'The saved {0} version goes offline and becomes a draft.':
'La version {0} enregistrée passe hors ligne et redevient un brouillon.',
'The {0} version goes back to draft and stops being public.':
'La version {0} redevient un brouillon et n’est plus publique.',
'Unpublish {0}?': 'Dépublier {0} ?',
'Your edits stay in the editor': 'Vos modifications restent dans l’éditeur',
'Your unsaved changes are kept here. Nothing is saved when you unpublish. Save them yourself when you are ready.':
'Vos modifications non enregistrées sont conservées ici. Rien n’est enregistré quand vous dépubliez. Enregistrez-les vous-même quand vous êtes prêt.',
"Other versions weren't changed.": 'Les autres versions n’ont pas été modifiées.',
"Couldn't save {0}": 'Impossible d’enregistrer {0}',
'Nothing was published. Your changes are kept. Try again.':
'Rien n’a été publié. Vos modifications sont conservées. Réessayez.',
'{0} unpublished': '{0} dépublié',
"Couldn't publish {0}": 'Impossible de publier {0}',
"Couldn't unpublish {0}": 'Impossible de dépublier {0}',
"Couldn't open the {0} version": 'Impossible d’ouvrir la version {0}',
"You're still editing {0}. Try again.": 'Vous modifiez toujours {0}. Réessayez.',
'Editing {0} version': 'Modification de la version {0}',
'Try saving again': 'Réessayer d’enregistrer',
'Title ({0})': 'Titre ({0})',
'Restored unsaved changes from {0}': 'Modifications non enregistrées restaurées de {0}',
'You logged in again. Your {0} title and body were kept in this browser.':
'Vous vous êtes reconnecté. Votre titre et votre texte en {0} ont été conservés dans ce navigateur.',
'You logged in again. Your title and body were kept in this browser.':
'Vous vous êtes reconnecté. Votre titre et votre texte ont été conservés dans ce navigateur.',
"Couldn't save": 'Impossible d’enregistrer',
'Your changes are kept here. Nothing was published. Try saving again.':
'Vos modifications sont conservées ici. Rien n’a été publié. Réessayez d’enregistrer.',
'Saved as a draft. Publishing failed. Try publishing again.':
'Enregistré comme brouillon. La publication a échoué. Réessayez de publier.',
'Your edits are saved. Only the publishing step failed.':
'Vos modifications sont enregistrées. Seule l’étape de publication a échoué.',
'{0} is disabled on this site. Re-enable it to publish.':
'{0} est désactivé sur ce site. Réactivez-le pour publier.',
'You can still edit and save this version. Ask an owner to re-enable {0}.':
'Vous pouvez toujours modifier et enregistrer cette version. Demandez à un propriétaire de réactiver {0}.',
"You can read this version. You can't edit it.":
'Vous pouvez lire cette version. Vous ne pouvez pas la modifier.',
'The source reference and the versions you can read stay available.':
'La référence source et les versions que vous pouvez lire restent disponibles.',
'You can publish this version but not edit it':
'Vous pouvez publier cette version mais pas la modifier',
'Fields are read-only. Publish stays available.':
'Les champs sont en lecture seule. La publication reste disponible.',
'Publishing needs the publish permission': 'Publier demande l’autorisation de publier',
'Saving keeps it as a draft.': 'L’enregistrement la garde en brouillon.',
'This version is online. Saving updates the live page right away.':
'Cette version est en ligne. L’enregistrement met à jour la page publique immédiatement.',
'Not available yet.': 'Pas encore disponible.',
'Move article': 'Déplacer l’article',
"You can't delete this article.": 'Vous ne pouvez pas supprimer cet article.',
'{0} saved': '{0} enregistré',
"{0} wasn't changed.": '{0} n’a pas été modifié.',
"Each language has its own title. Changing it doesn't change the URL; edit the slug in Search and sharing.":
'Chaque langue a son propre titre. Le modifier ne change pas l’URL ; modifiez le slug dans Recherche et partage.',
"Changing the title doesn't change the URL; edit the slug in Search and sharing.":
'Modifier le titre ne change pas l’URL ; modifiez le slug dans Recherche et partage.',
} as const
+2 -8
View File
@@ -1,9 +1,11 @@
import { articleListMessages } from './articleListMessages'
import { editorMessages } from './editorMessages'
import { localizationMessages } from './localizationMessages'
export const messages = {
...localizationMessages,
...articleListMessages,
...editorMessages,
'Not found': 'Introuvable',
'Edit article': 'Modifier l’article',
unselected: 'désélectionnée',
@@ -241,8 +243,6 @@ export const messages = {
'Clear search': 'Effacer la recherche',
Title: 'Titre',
'3 to 200 characters.': 'De 3 à 200 caractères.',
"The title can't be changed after creation.":
'Le titre ne peut pas être modifié après la création.',
'It starts as a draft, so nothing appears on your site until you publish it.':
'Il commence comme brouillon : rien n’apparaît sur votre site avant sa publication.',
"It's saved as a draft. Titles can't be changed later, so check it now.":
@@ -303,8 +303,6 @@ export const messages = {
'Something went wrong on the server. Try again in a moment.':
'Une erreur est survenue sur le serveur. Réessayez dans un instant.',
'Live. Your website can read this article.': 'Publié. Votre site peut lire cet article.',
"Titles can't be changed after an article is created. To use a different title, create a new article and delete this one.":
'Les titres ne peuvent pas être modifiés après la création d’un article. Pour changer de titre, créez un nouvel article et supprimez celui-ci.',
'Back to Articles': 'Retour aux articles',
'Back to Integration': 'Retour à l’intégration',
'Your changes to "{0}" haven\'t been saved.':
@@ -775,9 +773,6 @@ export const messages = {
'Something went wrong. Try again.': 'Une erreur est survenue. Réessayez.',
"This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.":
'Cette page n’a pas pu se charger. Votre travail enregistré est conservé. Réessayez et, si le problème persiste, redémarrez le serveur RenewCMS.',
"This article doesn't exist.": 'Cet article n’existe pas.',
'It may have been deleted, or the link is wrong.':
'Il a peut-être été supprimé ou le lien est incorrect.',
Dismiss: 'Fermer',
"You're offline. You can keep writing, but saving and publishing will wait until you're back online.":
'Vous êtes hors ligne. Vous pouvez continuer à écrire, mais l’enregistrement et la publication attendront le retour de la connexion.',
@@ -2910,7 +2905,6 @@ export const messages = {
'You can write and edit all articles. Publishing is done by an editor.':
'Vous pouvez rédiger et modifier tous les articles. La publication est faite par un éditeur.',
"Your role can't delete files.": 'Votre rôle ne permet pas de supprimer des fichiers.',
'Save draft': 'Enregistrer le brouillon',
'The id of the role to give. Absent or 0 means the Writer role. The Owner role is refused.':
'L’identifiant du rôle à donner. Absent ou 0, c’est le rôle Rédacteur. Le rôle Propriétaire est refusé.',
Access: 'Accès',
-1
View File
@@ -25,7 +25,6 @@ const KEYS = [
"Your role can't delete articles.",
'Ask an editor to publish.',
'You can write and edit all articles. Publishing is done by an editor.',
'Save draft',
] as const
describe('Permission copy', () => {
+12 -3
View File
@@ -14,6 +14,7 @@ import { messages } from './messages'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
import { formatSize, pluralArticles } from '@/lib/mediaFormat'
import { storyEnvelope, storyListUrl } from '@/views/__tests__/articleEnvelope'
import type { ArticleCapability } from '@/lib/articleTypes'
import { accessFor, ownerAccess } from '@/test/access'
vi.mock('@/composables/useApi', async () => {
@@ -367,6 +368,7 @@ describe('Hardener localization outcomes', () => {
title: 'Data title',
body: '',
is_online: false,
capabilities: ['read', 'edit', 'publish', 'delete'] as ArticleCapability[],
created_at: '2025-02-14T12:00:00Z',
updated_at: '2025-02-14T12:00:00Z',
}
@@ -438,6 +440,7 @@ describe('Hardener localization outcomes', () => {
body: '<p><img src="/static/uploadedImages/photo.png"></p>',
is_online: false,
images: [],
capabilities: ['read', 'edit', 'publish', 'delete'] as ArticleCapability[],
created_at: '2025-02-14T12:00:00Z',
updated_at: '2025-02-14T12:00:00Z',
}
@@ -459,10 +462,15 @@ describe('Hardener localization outcomes', () => {
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find((b) => b.textContent?.trim() === 'Delete…')!
.click()
} else
Array.from(document.body.querySelectorAll<HTMLButtonElement>('button'))
.find((b) => b.textContent?.trim() === 'Delete')!
} else {
document.body
.querySelector<HTMLButtonElement>('[aria-label="More actions"]')!
.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find((b) => b.textContent?.trim() === 'Delete article')!
.click()
}
await flushPromises()
const snapshots = []
for (const language of ['en', 'fr', 'en'] as const) {
@@ -498,6 +506,7 @@ describe('Hardener localization outcomes', () => {
body: '',
images: [],
is_online: false,
capabilities: ['read', 'edit', 'publish', 'delete'] as ArticleCapability[],
created_at: '2025-02-14T12:00:00Z',
updated_at: '2025-02-14T12:00:00Z',
})