Add a shared stateless article workspace for Filament hosts
Verify WYSIWYG package / Full verification (pull_request) Successful in 1m0s
Verify WYSIWYG package / Immutable editor package (pull_request) Skipped
Verify WYSIWYG package / Full verification (push) Successful in 1m1s
Verify WYSIWYG package / Immutable editor package (push) Successful in 17s
Verify WYSIWYG package / Full verification (pull_request) Successful in 1m0s
Verify WYSIWYG package / Immutable editor package (pull_request) Skipped
Verify WYSIWYG package / Full verification (push) Successful in 1m1s
Verify WYSIWYG package / Immutable editor package (push) Successful in 17s
This commit was merged in pull request #6.
This commit is contained in:
@@ -85,6 +85,17 @@ Publishing, permission checks, category/author records and cache policy remain
|
||||
outside this package. Serve drafts and private image previews with no-store;
|
||||
cache approved public projections in the website's existing Laravel cache.
|
||||
|
||||
`ArticleWorkbench` (or `mountArticleWorkbench`) is an optional shared article list,
|
||||
top-right language switcher and category/author selector. Its `api` implements the
|
||||
News adapter's explicit actions; all authorization, immutable revisions and canonical
|
||||
readback remain server-owned. `scope` must identify the authenticated administrator
|
||||
and installation; without it browser recovery is disabled. Category/author management
|
||||
and final publication stay in the owning App, opened through trusted host callbacks.
|
||||
For Filament use `mountFilamentArticleWorkbench(element, $wire, props)` with the
|
||||
News adapter's stateless `InteractsWithNewsEditor` trait. This uses the same failure-aware
|
||||
Livewire transport as page editing, not a second REST API. It never retries automatically.
|
||||
Always destroy the mount on navigation. This workspace does not migrate legacy archives.
|
||||
|
||||
Protected-main CI publishes an exact-SHA Composer ZIP, npm tarball, Composer
|
||||
descriptor and SHA-256 manifest. Article consumers must install those immutable
|
||||
artifacts, not a branch checkout: generated bundles are not tracked in Git.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@tropikal/filament-wysiwyg",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@tropikal/filament-wysiwyg",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@babel/parser": "7.28.5",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@tropikal/filament-wysiwyg",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "Small framework-independent inline editing engine for Filament website previews",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ArticleDocumentEditor } from './ArticleDocumentEditor.jsx';
|
||||
import { Button, Select } from './controls.jsx';
|
||||
import en from './locales/en.json';
|
||||
import de from './locales/de.json';
|
||||
|
||||
const messages = {en,de};
|
||||
const validDraft = value => value?.publication_id && value.article_id && value.revision_id
|
||||
&& value.content && value.article?.revision_id === value.revision_id;
|
||||
|
||||
/** Host callbacks own authentication/transport; this workspace never caches server drafts. */
|
||||
export function ArticleWorkbench({api,scope,uiLocale='en',resolvePreview,onOpenReview,onManageMetadata}) {
|
||||
const t=key=>messages[uiLocale]?.[key] ?? en[key];
|
||||
const mounted=useRef(false);
|
||||
const attempt=useRef(null);
|
||||
const [catalogue,setCatalogue]=useState(null);
|
||||
const [items,setItems]=useState([]);
|
||||
const [cursor,setCursor]=useState(null);
|
||||
const [draft,setDraft]=useState(null);
|
||||
const [category,setCategory]=useState('');
|
||||
const [author,setAuthor]=useState('');
|
||||
const [busy,setBusy]=useState(true);
|
||||
const [blocked,setBlocked]=useState(false);
|
||||
const [error,setError]=useState('');
|
||||
const [retry,setRetry]=useState(0);
|
||||
const onBlocked=useCallback(value=>setBlocked(value),[]);
|
||||
useEffect(()=>{
|
||||
mounted.current=true;
|
||||
return ()=>{mounted.current=false;};
|
||||
},[]);
|
||||
useEffect(()=>{
|
||||
let cancelled=false;
|
||||
setBusy(true);setError('');
|
||||
Promise.all([api.newsMetadata(),api.newsArticles()]).then(([metadata,list])=>{
|
||||
if (cancelled) return;
|
||||
if (!Array.isArray(metadata.website?.locales) || !Array.isArray(metadata.categories)
|
||||
|| !Array.isArray(metadata.authors) || !Array.isArray(list.items)) throw Error();
|
||||
setCatalogue(metadata);setItems(list.items);setCursor(list.next_cursor);
|
||||
}).catch(()=>{if(!cancelled)setError(t('actionFailed'));})
|
||||
.finally(()=>{if(!cancelled)setBusy(false);});
|
||||
return ()=>{cancelled=true;};
|
||||
},[api,retry,uiLocale]);
|
||||
function accept(value) {
|
||||
if (!validDraft(value)) throw Error();
|
||||
if (!mounted.current) return;
|
||||
setDraft(value);setCategory(value.content.category_id || '');setAuthor(value.content.author_id || '');
|
||||
setItems(current=>[{publication_id:value.publication_id,title:value.article.title},...current.filter(row=>row.publication_id!==value.publication_id)]);
|
||||
setBlocked(false);attempt.current=null;
|
||||
}
|
||||
const metadataDirty=Boolean(draft && (category!==(draft.content.category_id || '') || author!==(draft.content.author_id || '')));
|
||||
const locked=busy || blocked || metadataDirty;
|
||||
async function perform(operation) {
|
||||
setBusy(true);setError('');
|
||||
try {await operation();}
|
||||
catch {if(mounted.current)setError(t('actionFailed'));}
|
||||
finally {if(mounted.current)setBusy(false);}
|
||||
}
|
||||
function deliveryKey(identity) {
|
||||
if(attempt.current?.identity!==identity)attempt.current={identity,key:crypto.randomUUID()};
|
||||
return attempt.current.key;
|
||||
}
|
||||
async function save(content) {
|
||||
setBusy(true);setError('');
|
||||
const value={...draft.content,...content,category_id:category || null,author_id:author || null};
|
||||
const key=deliveryKey(JSON.stringify([draft.article_id,draft.revision_id,value]));
|
||||
try {
|
||||
const saved=await api.saveNewsDraft(value,key,draft.article_id,draft.revision_id);
|
||||
if(!validDraft(saved) || saved.publication_id!==draft.publication_id)throw Error();
|
||||
accept(saved);
|
||||
return {revision_id:saved.revision_id};
|
||||
} catch {throw Error(t('actionFailed'));}
|
||||
finally {if(mounted.current)setBusy(false);}
|
||||
}
|
||||
async function changeLanguage(locale) {
|
||||
if(locked || locale===draft.content.locale)return;
|
||||
await perform(async()=>{
|
||||
const target=draft.variants?.find(value=>value.locale===locale)?.publication_id;
|
||||
const value=target ? await api.newsDraft(target) : await api.newsVariant(draft.publication_id,draft.revision_id,locale,
|
||||
deliveryKey(JSON.stringify(['variant',draft.revision_id,locale])));
|
||||
if(value.content?.locale!==locale)throw Error();
|
||||
accept(value);
|
||||
});
|
||||
}
|
||||
async function uploadImage(file,description,requestId) {
|
||||
const encoded=await new Promise((resolve,reject)=>{
|
||||
const reader=new FileReader();reader.onerror=()=>reject(Error());
|
||||
reader.onload=()=>resolve(String(reader.result).split(',')[1]);reader.readAsDataURL(file);
|
||||
});
|
||||
return api.uploadNewsImage(encoded,description,requestId);
|
||||
}
|
||||
const options=(rows,current,label)=>[{value:'',label:'—'},
|
||||
...(current && !rows.some(value=>value.id===current) ? [{value:current,label}] : []),
|
||||
...rows.map(value=>({value:value.id,label:value.name || value.labels[draft.content.locale] || Object.values(value.labels)[0]}))];
|
||||
return <section className="article-workbench" aria-busy={busy}>
|
||||
<header className="article-workbench-topbar">
|
||||
<Button disabled={locked || !catalogue} onClick={()=>void perform(async()=>accept(await api.newNewsDraft(
|
||||
catalogue.website.primary_locale,deliveryKey('new'))))}>{t('newArticle')}</Button>
|
||||
{draft && <Select data-article-language label={t('language')} value={draft.content.locale} disabled={locked}
|
||||
options={[...new Set([...catalogue.website.locales,draft.content.locale])].map(value=>({value,label:value}))}
|
||||
onChange={event=>void changeLanguage(event.target.value)} />}
|
||||
</header>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{!catalogue && !busy && <Button onClick={()=>setRetry(value=>value+1)}>{t('retry')}</Button>}
|
||||
<div className="article-workbench-layout"><nav aria-label={t('articles')}>
|
||||
{items.map(value=><Button key={value.publication_id} disabled={locked} onClick={()=>void perform(async()=>accept(await api.newsDraft(value.publication_id)))}>
|
||||
{value.title || t('untitled')}</Button>)}
|
||||
{cursor && <Button disabled={locked} onClick={()=>void perform(async()=>{
|
||||
const list=await api.newsArticles(cursor);if(!Array.isArray(list.items))throw Error();
|
||||
if(mounted.current){setItems(current=>[...current,...list.items.filter(value=>!current.some(row=>row.publication_id===value.publication_id))]);setCursor(list.next_cursor);}
|
||||
})}>{t('moreArticles')}</Button>}
|
||||
</nav><div>{draft && <>
|
||||
<details><summary>{t('metadata')}</summary>
|
||||
<Select label={t('category')} value={category} disabled={busy} options={options(catalogue.categories,category,draft.content.category)} onChange={event=>setCategory(event.target.value)} />
|
||||
<Select label={t('author')} value={author} disabled={busy} options={options(catalogue.authors,author,draft.content.author)} onChange={event=>setAuthor(event.target.value)} />
|
||||
{onManageMetadata && <Button disabled={locked} onClick={onManageMetadata}>{t('manageMetadata')}</Button>}
|
||||
</details>
|
||||
<ArticleDocumentEditor article={draft.article} documentKey={JSON.stringify([scope,draft.article_id])}
|
||||
saveToken={draft.article.content_digest} storageKey={scope ? JSON.stringify(['news-draft',scope,draft.article_id,draft.revision_id,draft.article.content_digest]) : null}
|
||||
uiLocale={uiLocale} requireComplete={false} citationLinksOnly={draft.article.authorship_kind!=='human'}
|
||||
externalDirty={metadataDirty} disabled={busy} onBlocked={onBlocked} onSave={save}
|
||||
resolvePreview={resolvePreview} onUploadImage={api.uploadNewsImage ? uploadImage : undefined} />
|
||||
{onOpenReview && <Button disabled={locked} onClick={()=>void perform(async()=>{
|
||||
const result=await api.prepareNewsPublication(draft.publication_id,draft.revision_id);
|
||||
if(!/^\/review\/review_[0-9a-f-]{36}$/.test(result.review_href || ''))throw Error();
|
||||
if(mounted.current)onOpenReview(result.review_href);
|
||||
})}>{t('preparePublication')}</Button>}
|
||||
</>}</div></div>
|
||||
</section>;
|
||||
}
|
||||
@@ -32,3 +32,12 @@
|
||||
.article-visual-editor dialog { max-width: min(600px, 90vw); color: inherit; background: Canvas; border: 1px solid var(--border-default, #cbd5e1); border-radius: .75rem; }
|
||||
.article-visual-editor dialog::backdrop { background: #0006; }
|
||||
|
||||
.article-workbench-topbar { display:flex; justify-content:space-between; align-items:center; gap:1rem; margin-block:1rem; }
|
||||
.article-workbench-layout { display:grid; grid-template-columns:minmax(140px,220px) minmax(0,1fr); gap:1.5rem; }
|
||||
.article-workbench-layout nav { display:flex; flex-direction:column; align-items:stretch; gap:.5rem; }
|
||||
.article-workbench button, .article-workbench select { min-height:44px; font:inherit; color:inherit; }
|
||||
.article-workbench button:disabled, .article-workbench select:disabled { opacity:.5; }
|
||||
.article-workbench label { display:grid; gap:.35rem; }
|
||||
.article-workbench details { margin-bottom:1rem; }
|
||||
@media (max-width:700px) { .article-workbench-layout { grid-template-columns:minmax(0,1fr); } .article-workbench-layout nav { max-height:200px; overflow:auto; } }
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { ArticleDocumentEditor } from './ArticleDocumentEditor.jsx';
|
||||
export { ArticleWorkbench } from './ArticleWorkbench.jsx';
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
{
|
||||
"newArticle": "Neuer Artikel",
|
||||
"language": "Sprache",
|
||||
"articles": "Artikel",
|
||||
"untitled": "Unbenannter Entwurf",
|
||||
"moreArticles": "Weitere Artikel",
|
||||
"metadata": "Kategorie und Autor",
|
||||
"category": "Kategorie",
|
||||
"author": "Autor",
|
||||
"manageMetadata": "Kategorien und Autoren verwalten",
|
||||
"preparePublication": "Zur Veröffentlichung prüfen",
|
||||
"retry": "Erneut versuchen",
|
||||
"actionFailed": "Die Aktion konnte nicht bestätigt werden. Versuche es erneut.",
|
||||
"body": "Artikeltext",
|
||||
"chooseImage": "Bild auswählen",
|
||||
"imageDescription": "Bildbeschreibung",
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
{
|
||||
"newArticle": "New article",
|
||||
"language": "Language",
|
||||
"articles": "Articles",
|
||||
"untitled": "Untitled draft",
|
||||
"moreArticles": "More articles",
|
||||
"metadata": "Category and author",
|
||||
"category": "Category",
|
||||
"author": "Author",
|
||||
"manageMetadata": "Manage categories and authors",
|
||||
"preparePublication": "Review for publication",
|
||||
"retry": "Retry",
|
||||
"actionFailed": "The action could not be confirmed. Try again.",
|
||||
"body": "Article body",
|
||||
"chooseImage": "Choose image",
|
||||
"imageDescription": "Image description",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { ArticleDocumentEditor } from './ArticleDocumentEditor.jsx';
|
||||
import { ArticleWorkbench } from './ArticleWorkbench.jsx';
|
||||
import { callLivewire } from '../index.js';
|
||||
|
||||
/** Mount the same editor in a non-React host; callbacks remain host-owned. */
|
||||
export function mountArticleEditor(element, initialProps) {
|
||||
@@ -10,3 +12,19 @@ export function mountArticleEditor(element, initialProps) {
|
||||
destroy() { root.unmount(); },
|
||||
};
|
||||
}
|
||||
|
||||
/** Shared list/language/metadata host for Filament versions without a React runtime. */
|
||||
export function mountArticleWorkbench(element, props) {
|
||||
const root = createRoot(element);
|
||||
root.render(<ArticleWorkbench key={props.scope} {...props} />);
|
||||
return {destroy() {root.unmount();}};
|
||||
}
|
||||
|
||||
/** Only explicit News actions are reachable; failed Livewire commits reject retries. */
|
||||
export function mountFilamentArticleWorkbench(element, wire, props) {
|
||||
const api=Object.fromEntries(['newsMetadata','newsArticles','newsDraft','newNewsDraft','saveNewsDraft',
|
||||
'newsVariant','prepareNewsPublication','uploadNewsImage'].map(method=>[
|
||||
method,(...args)=>callLivewire(wire,method,...args),
|
||||
]));
|
||||
return mountArticleWorkbench(element,{...props,api});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
const settle = () => new Promise(resolve => setTimeout(resolve, 40));
|
||||
|
||||
test('workbench fences retries to the saved revision and blocks language changes with unsaved text', async () => {
|
||||
const dom = new JSDOM('<div id="editor"></div>', {url:'https://fixture.example', pretendToBeVisual:true});
|
||||
for (const name of ['window','document','HTMLElement','HTMLInputElement','HTMLTextAreaElement','Node','MutationObserver','sessionStorage']) {
|
||||
Object.defineProperty(globalThis,name,{value:dom.window[name],configurable:true});
|
||||
}
|
||||
Object.defineProperty(globalThis,'navigator',{value:dom.window.navigator,configurable:true});
|
||||
globalThis.requestAnimationFrame=dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame=dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
globalThis.getComputedStyle=dom.window.getComputedStyle.bind(dom.window);
|
||||
const {mountFilamentArticleWorkbench}=await import('../dist/article-editor.js');
|
||||
const draft=(revision='revision-1',title='Original')=>({publication_id:'publication',article_id:'article',revision_id:revision,
|
||||
content:{locale:'en',title,excerpt:'',markdown:'Note',category_id:null,author_id:null},
|
||||
article:{title,excerpt:'',markdown:'Note',editable_markdown:'Note',locale:'en',revision_id:revision,
|
||||
content_digest:revision,image_blocks:[],citations:[]},variants:[]});
|
||||
const saves=[];let fail=true;
|
||||
let hooks=0,removed=0;
|
||||
const host=mountFilamentArticleWorkbench(document.getElementById('editor'),{
|
||||
$hook(name){assert.equal(name,'commit');hooks++;return()=>removed++;},
|
||||
newsMetadata:async()=>({website:{locales:['en','de'],primary_locale:'en'},categories:[],authors:[]}),
|
||||
newsArticles:async()=>({items:[{publication_id:'publication',title:'Original'}],next_cursor:null}),
|
||||
newsDraft:async()=>draft(),
|
||||
saveNewsDraft:async(...args)=>{saves.push(args);if(fail){await new Promise(resolve=>setTimeout(resolve,100));throw Error('Private traceback');}return draft('revision-2','Changed');},
|
||||
},{scope:'fixture/admin/site'});
|
||||
try {
|
||||
await settle();await settle();
|
||||
const button=label=>[...document.querySelectorAll('button')].find(item=>item.textContent===label);
|
||||
button('Original').click();await settle();await settle();
|
||||
const title=document.querySelector('.article-visual-title input');
|
||||
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype,'value').set.call(title,'Changed');
|
||||
title.dispatchEvent(new dom.window.Event('input',{bubbles:true}));await settle();
|
||||
assert.equal(document.querySelector('[data-article-language]').disabled,true);
|
||||
button('Save changes').click();await settle();
|
||||
assert.ok([...document.querySelectorAll('select')].every(select=>select.disabled),'Metadata cannot change while an acknowledged save is pending');
|
||||
await new Promise(resolve=>setTimeout(resolve,100));
|
||||
assert.equal(document.querySelector('[role="alert"]').textContent,'The action could not be confirmed. Try again.');
|
||||
fail=false;button('Save changes').click();await settle();await settle();
|
||||
assert.equal(saves.length,2);assert.equal(saves[0][1],saves[1][1]);
|
||||
assert.equal(saves[1][2],'article');assert.equal(saves[1][3],'revision-1');
|
||||
assert.equal(document.querySelector('[data-article-language]').disabled,false);
|
||||
assert.equal(document.querySelector('.article-visual-title input').value,'Changed');
|
||||
assert.equal(sessionStorage.length,0,'Only acknowledged recovery may be removed');
|
||||
assert.ok(hooks>=5);assert.equal(hooks,removed,'Every completed request releases its failure hook');
|
||||
} finally {host.destroy();dom.window.close();}
|
||||
});
|
||||
Reference in New Issue
Block a user