mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 15:36:18 +00:00
Merge pull request #237 from simoleo89/feat/furni-editor-create-missing
feat(furni-editor): create furnidata entry when missing (upsert Save)
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
# Sync `public_name` from furnidata (Furni Editor) — design
|
||||||
|
|
||||||
|
**Date:** 2026-06-14
|
||||||
|
**Repo:** `ui` (Nitro-V3 React client) — client-only
|
||||||
|
**Status:** approved (brainstorming)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
In the Furni Editor, the **"Public Name (DB fallback)"** field is `items_base.public_name`. When a furni has a furnidata entry with a display `name` but its DB `public_name` is empty, the two are out of sync. The 10046 edit/create handler already mirrors the furnidata name into `public_name` going forward (step "5b"), but **existing** furni that were never edited via the new editor keep an empty `public_name`. We want a per-furni manual way to fill the empty DB field from the present furnidata name.
|
||||||
|
|
||||||
|
## Scope (decided)
|
||||||
|
|
||||||
|
- **Per-furni**, inside the editor (not bulk).
|
||||||
|
- **Manual** trigger (a button), not automatic-on-open.
|
||||||
|
- Direction: `items_base.public_name` ← `furnidata.name` (only when DB is empty).
|
||||||
|
|
||||||
|
## Approach (A — chosen)
|
||||||
|
|
||||||
|
Reuse the existing generic item-update path. No new packet, **no server change**.
|
||||||
|
|
||||||
|
- The server `FurniEditorUpdateEvent` already runs `UPDATE items_base SET <fields> WHERE id` and `public_name` is in `ALLOWED_UPDATE_FIELDS`.
|
||||||
|
- The client already has the `update` action (`FurniEditorUpdateComposer`) whose 10044 success handler shows the "Item updated successfully" toast and re-fetches the detail.
|
||||||
|
|
||||||
|
Rejected: (B) dedicated sync packet/handler — needs a new packet + renderer registration + emulator rebuild/restart (Codex active); overkill. (C) re-trigger 10046 — gated on "dirty" and would broadcast an unnecessary 10047.
|
||||||
|
|
||||||
|
## Components (2 files, client only)
|
||||||
|
|
||||||
|
1. `src/hooks/furni-editor/useFurniEditor.ts`
|
||||||
|
- Add `syncPublicName(id: number, name: string)`: sets `pendingActionRef = { action: 'update', itemId: id }` and sends `FurniEditorUpdateComposer(id, JSON.stringify({ publicName: name }))`. Sets `loading`. Export it from the hook.
|
||||||
|
- Reuses the existing `FurniEditorResultEvent` (10044) handler: on success → "Item updated successfully" + re-fetch detail; on failure → existing alert + re-fetch revert.
|
||||||
|
|
||||||
|
2. `src/components/furni-editor/views/FurniEditorEditView.tsx`
|
||||||
|
- Add a small **"Sync from furnidata"** button next to the read-only "Public Name (DB fallback)" field (Basic Info section).
|
||||||
|
- Thread `onSyncPublicName` through props from the parent that wires the hook (same place `onUpdateFurnidata` etc. are wired).
|
||||||
|
|
||||||
|
## Button visibility (all three true)
|
||||||
|
|
||||||
|
1. `furnidataEditable` — entry exists AND classname matches (never sync a mismatched classname's name).
|
||||||
|
2. DB empty: `!String(item.publicName ?? '').trim()`.
|
||||||
|
3. furnidata name present: `!!String(furniDataEntry?.name ?? '').trim()`.
|
||||||
|
|
||||||
|
Disabled while `loading`. Name synced is the **stored** `furniDataEntry.name`, not the editable `furniName` state (avoid syncing an unsaved edit).
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
click → `syncPublicName(item.id, furniDataEntry.name)` → `FurniEditorUpdateComposer({ publicName })` → server `UPDATE items_base.public_name` → 10044 success → toast + detail re-fetch → field shows the name, button condition no longer met → button disappears.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
Inherited from the existing 10044 failure path (alert with server message + detail re-fetch to revert). No new handling needed.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Primary: runtime verification via Claude-in-Chrome — pick a furni with empty `public_name` + a furnidata name (Search list shows DB-empty rows with the 🎵 placeholder), click "Sync from furnidata", confirm the wire (10045/`update` → 10044 success), the DB field now shows the name, and the button disappears. Confirm `items_base.public_name` updated in the DB.
|
||||||
|
- Optional unit test: `syncPublicName` sends the right composer payload; visibility predicate.
|
||||||
|
|
||||||
|
## Branch
|
||||||
|
|
||||||
|
Commit on the active `feat/furni-editor-create-missing` branch (related furni-editor work) unless a separate PR is preferred. Client-only; no renderer/emulator change.
|
||||||
@@ -19,7 +19,7 @@ export const FurniEditorView: FC<{}> = () =>
|
|||||||
selectedItem, setSelectedItem, furniDataEntry, furniDataDiagnostic,
|
selectedItem, setSelectedItem, furniDataEntry, furniDataDiagnostic,
|
||||||
interactions,
|
interactions,
|
||||||
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions,
|
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions,
|
||||||
updateFurnidata, revertFurnidata, importText, importResult
|
updateFurnidata, revertFurnidata, syncPublicName, importText, importResult
|
||||||
} = useFurniEditor();
|
} = useFurniEditor();
|
||||||
|
|
||||||
const isMod = useHasPermission('acc_catalogfurni');
|
const isMod = useHasPermission('acc_catalogfurni');
|
||||||
@@ -159,6 +159,7 @@ export const FurniEditorView: FC<{}> = () =>
|
|||||||
onBack={ handleBack }
|
onBack={ handleBack }
|
||||||
onUpdateFurnidata={ updateFurnidata }
|
onUpdateFurnidata={ updateFurnidata }
|
||||||
onRevertFurnidata={ revertFurnidata }
|
onRevertFurnidata={ revertFurnidata }
|
||||||
|
onSyncPublicName={ syncPublicName }
|
||||||
onImportText={ importText }
|
onImportText={ importText }
|
||||||
importResult={ importResult }
|
importResult={ importResult }
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface FurniEditorEditViewProps
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onUpdateFurnidata: (id: number, name: string, description: string) => void;
|
onUpdateFurnidata: (id: number, name: string, description: string) => void;
|
||||||
onRevertFurnidata: (id: number) => void;
|
onRevertFurnidata: (id: number) => void;
|
||||||
|
onSyncPublicName: (id: number, name: string) => void;
|
||||||
onImportText: (id: number) => void;
|
onImportText: (id: number) => void;
|
||||||
importResult: { found: boolean; name: string; description: string; classname: string; nonce: number } | null;
|
importResult: { found: boolean; name: string; description: string; classname: string; nonce: number } | null;
|
||||||
}
|
}
|
||||||
@@ -122,7 +123,7 @@ const CopyValue: FC<{ value: string | number }> = ({ value }) =>
|
|||||||
|
|
||||||
export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
||||||
{
|
{
|
||||||
const { item, furniDataEntry, furniDataDiagnostic, interactions, loading, onUpdate, onDelete, onBack, onUpdateFurnidata, onRevertFurnidata, onImportText, importResult } = props;
|
const { item, furniDataEntry, furniDataDiagnostic, interactions, loading, onUpdate, onDelete, onBack, onUpdateFurnidata, onRevertFurnidata, onSyncPublicName, onImportText, importResult } = props;
|
||||||
const saveRef = useRef<() => void>(null);
|
const saveRef = useRef<() => void>(null);
|
||||||
|
|
||||||
const [ form, setForm ] = useState({
|
const [ form, setForm ] = useState({
|
||||||
@@ -245,6 +246,21 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
|||||||
return cn ? (cn === itemCn) : true;
|
return cn ? (cn === itemCn) : true;
|
||||||
}, [ furniDataEntry, item ]);
|
}, [ furniDataEntry, item ]);
|
||||||
|
|
||||||
|
// No furnidata entry at all → the editor can CREATE one (the server upserts:
|
||||||
|
// it builds a complete entry from items_base on save). Distinct from the
|
||||||
|
// classname-mismatch case (an entry resolved by id but for a different
|
||||||
|
// classname), which stays locked to avoid an id collision.
|
||||||
|
const furnidataCreatable = useMemo(() => !furniDataEntry, [ furniDataEntry ]);
|
||||||
|
|
||||||
|
// Show a one-click "sync" when the DB public_name is empty but the (matching)
|
||||||
|
// furnidata entry already has a name — fills items_base.public_name from the
|
||||||
|
// stored furnidata name so the DB fallback stops being blank.
|
||||||
|
const canSyncPublicName = useMemo(() =>
|
||||||
|
furnidataEditable &&
|
||||||
|
!String(form.publicName ?? '').trim() &&
|
||||||
|
!!String(furniDataEntry?.name ?? '').trim(),
|
||||||
|
[ furnidataEditable, form.publicName, furniDataEntry ]);
|
||||||
|
|
||||||
// True only when the name/description actually differ from the stored furnidata
|
// True only when the name/description actually differ from the stored furnidata
|
||||||
// entry. Used to gate the Save button: saving an unchanged value makes the
|
// entry. Used to gate the Save button: saving an unchanged value makes the
|
||||||
// server writer return false, which the handler misreports as "Classname not
|
// server writer return false, which the handler misreports as "Classname not
|
||||||
@@ -364,16 +380,18 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
|||||||
<Text className="text-[12px] font-semibold text-slate-700">Display name & description</Text>
|
<Text className="text-[12px] font-semibold text-slate-700">Display name & description</Text>
|
||||||
{ furnidataEditable
|
{ furnidataEditable
|
||||||
? <span className="text-[9px] font-semibold text-primary bg-primary/10 rounded-md px-1.5 py-0.5">LIVE</span>
|
? <span className="text-[9px] font-semibold text-primary bg-primary/10 rounded-md px-1.5 py-0.5">LIVE</span>
|
||||||
|
: furnidataCreatable
|
||||||
|
? <span className="text-[9px] font-semibold text-emerald-700 bg-emerald-100 rounded-md px-1.5 py-0.5">NEW</span>
|
||||||
: <span className="text-[9px] font-semibold text-amber-700 bg-amber-100 rounded-md px-1.5 py-0.5">NO FURNIDATA</span> }
|
: <span className="text-[9px] font-semibold text-amber-700 bg-amber-100 rounded-md px-1.5 py-0.5">NO FURNIDATA</span> }
|
||||||
{ furnidataEditable && furnidataDirty &&
|
{ furnidataEditable && furnidataDirty &&
|
||||||
<span className="ml-auto text-[10px] text-amber-600 font-medium">Unsaved</span> }
|
<span className="ml-auto text-[10px] text-amber-600 font-medium">Unsaved</span> }
|
||||||
</div>
|
</div>
|
||||||
{ furnidataEditable ? (
|
{ (furnidataEditable || furnidataCreatable) ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className={ labelClass }>Display Name (furnidata)</label>
|
<label className={ labelClass }>Display Name (furnidata)</label>
|
||||||
<input className={ inputClass() } value={ furniName } onChange={ e => setFurniName(e.target.value) } maxLength={ 256 } />
|
<input className={ inputClass() } value={ furniName } onChange={ e => setFurniName(e.target.value) } maxLength={ 256 } placeholder={ furnidataCreatable ? (form.publicName || form.itemName) : undefined } />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={ labelClass }>Description</label>
|
<label className={ labelClass }>Description</label>
|
||||||
@@ -381,7 +399,9 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Flex gap={ 1 } className="mt-1.5" alignItems="center">
|
<Flex gap={ 1 } className="mt-1.5" alignItems="center">
|
||||||
<Button variant="success" disabled={ loading || !furnidataDirty } onClick={ () => setConfirmFurnidata(true) }>Save name/desc</Button>
|
<Button variant="success" disabled={ furnidataEditable ? (loading || !furnidataDirty) : loading } onClick={ () => setConfirmFurnidata(true) }>{ furnidataEditable ? 'Save name/desc' : 'Create entry' }</Button>
|
||||||
|
{ furnidataEditable &&
|
||||||
|
<>
|
||||||
<Button variant="secondary" disabled={ loading } onClick={ () => onRevertFurnidata(item.id) }>Revert</Button>
|
<Button variant="secondary" disabled={ loading } onClick={ () => onRevertFurnidata(item.id) }>Revert</Button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -393,14 +413,17 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
|||||||
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M10 3v9" /><path d="m6.5 8.5 3.5 3.5 3.5-3.5" /><path d="M4 16h12" /></svg>
|
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M10 3v9" /><path d="m6.5 8.5 3.5 3.5 3.5-3.5" /><path d="M4 16h12" /></svg>
|
||||||
Import from Habbo
|
Import from Habbo
|
||||||
</button>
|
</button>
|
||||||
|
</> }
|
||||||
</Flex>
|
</Flex>
|
||||||
|
{ furnidataCreatable &&
|
||||||
|
<Text className="mt-1 text-[10px] text-emerald-600">No furnidata entry yet — saving creates a complete one from the item data.</Text> }
|
||||||
{ importNote &&
|
{ importNote &&
|
||||||
<Text className={ `mt-1 text-[10px] ${ importNote.startsWith('Not found') ? 'text-amber-600' : 'text-primary' }` }>{ importNote }</Text> }
|
<Text className={ `mt-1 text-[10px] ${ importNote.startsWith('Not found') ? 'text-amber-600' : 'text-primary' }` }>{ importNote }</Text> }
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-start gap-2 text-[11px] text-slate-500 bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-2 leading-snug">
|
<div className="flex items-start gap-2 text-[11px] text-slate-500 bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-2 leading-snug">
|
||||||
<span className="text-[#f59e0b] text-sm leading-none mt-px">⚠</span>
|
<span className="text-[#f59e0b] text-sm leading-none mt-px">⚠</span>
|
||||||
<span>This furni has no matching <b>furnidata</b> entry ({ furnidataMissReason.replace(/_/g, ' ') }), so its display name can't be edited here. Clients fall back to the DB <b>Public Name</b> below.</span>
|
<span>A furnidata entry resolved by id but for a <b>different classname</b> ({ furnidataMissReason.replace(/_/g, ' ') }) — name editing is locked to avoid an id collision. Clients fall back to the DB <b>Public Name</b> below.</span>
|
||||||
</div>
|
</div>
|
||||||
) }
|
) }
|
||||||
</div>
|
</div>
|
||||||
@@ -414,6 +437,10 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
|||||||
<div>
|
<div>
|
||||||
<label className={ labelClass }>Public Name (DB fallback)</label>
|
<label className={ labelClass }>Public Name (DB fallback)</label>
|
||||||
<CopyValue value={ form.publicName } />
|
<CopyValue value={ form.publicName } />
|
||||||
|
{ canSyncPublicName &&
|
||||||
|
<Button variant="secondary" disabled={ loading } className="mt-1 w-full" onClick={ () => onSyncPublicName(item.id, String(furniDataEntry?.name ?? '')) }>
|
||||||
|
Sync from furnidata
|
||||||
|
</Button> }
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={ labelClass }>Sprite ID</label>
|
<label className={ labelClass }>Sprite ID</label>
|
||||||
|
|||||||
@@ -190,6 +190,15 @@ export const useFurniEditor = () =>
|
|||||||
{
|
{
|
||||||
setError(parser.message || 'Operation failed');
|
setError(parser.message || 'Operation failed');
|
||||||
|
|
||||||
|
// updateFurnidata applies an optimistic publicName change before the
|
||||||
|
// server replies. On failure (e.g. a sprite-id collision when creating
|
||||||
|
// an entry for a variant furni) re-fetch the detail so the UI reverts
|
||||||
|
// to the true state instead of showing the name that was never applied.
|
||||||
|
if(actionItemId)
|
||||||
|
{
|
||||||
|
SendMessageComposer(new FurniEditorDetailComposer(actionItemId));
|
||||||
|
}
|
||||||
|
|
||||||
if(simpleAlert)
|
if(simpleAlert)
|
||||||
{
|
{
|
||||||
simpleAlert(parser.message || 'Operation failed', NotificationAlertType.ALERT, null, null, 'Furni Editor Error');
|
simpleAlert(parser.message || 'Operation failed', NotificationAlertType.ALERT, null, null, 'Furni Editor Error');
|
||||||
@@ -282,6 +291,17 @@ export const useFurniEditor = () =>
|
|||||||
SendMessageComposer(new FurniEditorRevertFurnidataComposer(id));
|
SendMessageComposer(new FurniEditorRevertFurnidataComposer(id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Fill an empty items_base.public_name from the furnidata display name. Reuses
|
||||||
|
// the generic item update (a partial { publicName } payload is accepted), so the
|
||||||
|
// existing 'update' result path shows the toast and re-fetches the detail.
|
||||||
|
const syncPublicName = useCallback((id: number, name: string) =>
|
||||||
|
{
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
pendingActionRef.current = { action: 'update', itemId: id };
|
||||||
|
SendMessageComposer(new FurniEditorUpdateComposer(id, JSON.stringify({ publicName: name })));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const importText = useCallback((id: number) =>
|
const importText = useCallback((id: number) =>
|
||||||
{
|
{
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -314,6 +334,6 @@ export const useFurniEditor = () =>
|
|||||||
selectedItem, setSelectedItem, catalogItems, furniDataEntry, furniDataDiagnostic,
|
selectedItem, setSelectedItem, catalogItems, furniDataEntry, furniDataDiagnostic,
|
||||||
interactions,
|
interactions,
|
||||||
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions,
|
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions,
|
||||||
updateFurnidata, revertFurnidata, importText, importResult
|
updateFurnidata, revertFurnidata, syncPublicName, importText, importResult
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user