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

This commit was merged in pull request #6.
This commit is contained in:
botbob
2026-10-12 17:15:29 +07:00
parent f60a580e0b
commit a94bdc8a1e
10 changed files with 245 additions and 3 deletions
+11
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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",
+129
View File
@@ -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>;
}
+9
View File
@@ -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
View File
@@ -1 +1,2 @@
export { ArticleDocumentEditor } from './ArticleDocumentEditor.jsx';
export { ArticleWorkbench } from './ArticleWorkbench.jsx';
+12
View File
@@ -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",
+12
View File
@@ -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",
+18
View File
@@ -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});
}
+50
View File
@@ -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();}
});