refactor state storge, UI editor
This commit is contained in:
@@ -1,38 +1,8 @@
|
||||
import type { Entity } from "@/uhm/types/entities";
|
||||
import type { Feature, FeatureProperties } from "@/uhm/types/geo";
|
||||
import type { PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
import { normalizeFeatureEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||
import { newId } from "@/uhm/lib/id";
|
||||
|
||||
export function mergeEntitiesWithPending(
|
||||
persistedEntities: Entity[],
|
||||
pendingCreates: PendingEntityCreate[]
|
||||
): Entity[] {
|
||||
if (!pendingCreates.length) {
|
||||
return persistedEntities;
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const pendingAsEntities: Entity[] = [];
|
||||
for (const pending of pendingCreates) {
|
||||
if (seen.has(pending.id)) continue;
|
||||
seen.add(pending.id);
|
||||
pendingAsEntities.push({
|
||||
id: pending.id,
|
||||
name: pending.name,
|
||||
slug: pending.slug,
|
||||
type_id: pending.type_id,
|
||||
status: pending.status,
|
||||
geometry_count: 0,
|
||||
created_at: undefined,
|
||||
updated_at: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const nextPersisted = persistedEntities.filter((entity) => !seen.has(entity.id));
|
||||
return [...pendingAsEntities, ...nextPersisted];
|
||||
}
|
||||
|
||||
export function mergeEntitySearchResults(
|
||||
remoteRows: Entity[],
|
||||
localRows: Entity[]
|
||||
@@ -70,7 +40,7 @@ export function buildClientEntityId(): string {
|
||||
}
|
||||
|
||||
export function buildFeatureEntityPatch(
|
||||
feature: Feature,
|
||||
_feature: Feature,
|
||||
entityIds: string[],
|
||||
entities: Entity[]
|
||||
): Partial<FeatureProperties> {
|
||||
@@ -78,29 +48,14 @@ export function buildFeatureEntityPatch(
|
||||
const primaryEntity = primaryEntityId
|
||||
? entities.find((entity) => entity.id === primaryEntityId) || null
|
||||
: null;
|
||||
const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds, entities) ||
|
||||
feature.properties.type ||
|
||||
null;
|
||||
const entityNames = entityIds
|
||||
.map((id) => entities.find((entity) => entity.id === id)?.name || "")
|
||||
.filter((name) => name.length > 0);
|
||||
|
||||
return {
|
||||
type: nextGeometryType,
|
||||
entity_id: primaryEntityId,
|
||||
entity_ids: entityIds,
|
||||
entity_name: primaryEntity?.name || null,
|
||||
entity_names: entityNames,
|
||||
entity_type_id: primaryEntity?.type_id || null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveGeometryTypeFromEntityIds(
|
||||
entityIds: string[],
|
||||
entities: Entity[]
|
||||
): string | null {
|
||||
const primaryEntityId = entityIds[0] || null;
|
||||
if (!primaryEntityId) return null;
|
||||
const primaryEntity = entities.find((entity) => entity.id === primaryEntityId) || null;
|
||||
return primaryEntity?.type_id || null;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export type GeometryMetadataPatch = {
|
||||
};
|
||||
|
||||
export function buildGeometryMetadataPatch(form: GeometryMetaFormState): GeometryMetadataPatch {
|
||||
const typeKey = form.type_key.trim();
|
||||
const timeStart = parseOptionalYearInput(form.time_start, "time_start");
|
||||
const timeEnd = parseOptionalYearInput(form.time_end, "time_end");
|
||||
if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) {
|
||||
@@ -20,11 +21,13 @@ export function buildGeometryMetadataPatch(form: GeometryMetaFormState): Geometr
|
||||
const bindingIds = parseBindingInput(form.binding);
|
||||
return {
|
||||
patch: {
|
||||
type: typeKey.length ? typeKey : undefined,
|
||||
time_start: timeStart,
|
||||
time_end: timeEnd,
|
||||
binding: bindingIds,
|
||||
},
|
||||
formState: {
|
||||
type_key: typeKey,
|
||||
time_start: timeStart != null ? String(timeStart) : "",
|
||||
time_end: timeEnd != null ? String(timeEnd) : "",
|
||||
binding: bindingIds.join(", "),
|
||||
@@ -47,4 +50,3 @@ function parseOptionalYearInput(raw: string, fieldName: string): number | null {
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,10 @@ import {
|
||||
fetchSectionCommits,
|
||||
fetchSections,
|
||||
openSectionEditor,
|
||||
restoreSectionCommit,
|
||||
submitSection,
|
||||
} from "@/uhm/api/sections";
|
||||
import { buildEditorSnapshot, normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||
import type { CreatedEntitySummary, PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
import type { Feature, FeatureCollection, FeatureId } from "@/uhm/types/geo";
|
||||
import type { EditorSnapshot, Section, SectionCommit, SectionState, EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
@@ -34,24 +32,21 @@ type Options = {
|
||||
selectedSectionId: string;
|
||||
newSectionTitle: string;
|
||||
pendingSaveCount: number;
|
||||
pendingEntityCreates: PendingEntityCreate[];
|
||||
projectEntityRefs: EntitySnapshot[];
|
||||
wikis: WikiSnapshot[];
|
||||
entityWikiLinks: EntityWikiLinkSnapshot[];
|
||||
lastSectionSnapshot: EditorSnapshot | null;
|
||||
snapshotEntities: EntitySnapshot[];
|
||||
snapshotWikis: WikiSnapshot[];
|
||||
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
|
||||
baselineSnapshot: EditorSnapshot | null;
|
||||
commitTitle: string;
|
||||
commitNote: string;
|
||||
setActiveSection: Dispatch<SetStateAction<Section | null>>;
|
||||
setSelectedSectionId: Dispatch<SetStateAction<string>>;
|
||||
setSectionState: Dispatch<SetStateAction<SectionState | null>>;
|
||||
setLastSectionSnapshot: Dispatch<SetStateAction<EditorSnapshot | null>>;
|
||||
setBaselineSnapshot: Dispatch<SetStateAction<EditorSnapshot | null>>;
|
||||
setInitialData: Dispatch<SetStateAction<FeatureCollection>>;
|
||||
setSectionCommits: Dispatch<SetStateAction<SectionCommit[]>>;
|
||||
setPendingEntityCreates: Dispatch<SetStateAction<PendingEntityCreate[]>>;
|
||||
setProjectEntityRefs: Dispatch<SetStateAction<EntitySnapshot[]>>;
|
||||
setCreatedEntities: Dispatch<SetStateAction<CreatedEntitySummary[]>>;
|
||||
setWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
|
||||
setEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||
setSnapshotEntities: Dispatch<SetStateAction<EntitySnapshot[]>>;
|
||||
setSnapshotWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
|
||||
setSnapshotEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||
setSelectedFeatureId: Dispatch<SetStateAction<FeatureId | null>>;
|
||||
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
|
||||
setEntityStatus: Dispatch<SetStateAction<string | null>>;
|
||||
@@ -68,33 +63,21 @@ export function useSectionCommands(options: Options) {
|
||||
const openSectionForEditing = useCallback(async (sectionId: string) => {
|
||||
const editorPayload = await openSectionEditor(sectionId);
|
||||
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
|
||||
// When starting a fresh editor session from a commit snapshot, treat all rows as baseline state:
|
||||
// operations should not carry over as deltas into the next commit.
|
||||
const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null;
|
||||
const commits = await fetchSectionCommits(sectionId);
|
||||
const nextInitialData = snapshot?.editor_feature_collection || options.emptyFeatureCollection;
|
||||
const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
|
||||
|
||||
options.setActiveSection(editorPayload.section);
|
||||
options.setSelectedSectionId(editorPayload.section.id);
|
||||
options.setSectionState(editorPayload.state);
|
||||
options.setLastSectionSnapshot(snapshot);
|
||||
options.setBaselineSnapshot(sessionSnapshot);
|
||||
options.setInitialData(nextInitialData);
|
||||
options.setSectionCommits(commits);
|
||||
options.setPendingEntityCreates([]);
|
||||
options.setCreatedEntities([]);
|
||||
const geoEntityIds = new Set((snapshot?.geometry_entity || []).map((row) => row.entity_id));
|
||||
const linkedByWikiIds = new Set(
|
||||
(snapshot?.entity_wiki || [])
|
||||
.filter((l) => l?.operation !== "delete")
|
||||
.map((l) => l.entity_id)
|
||||
);
|
||||
options.setProjectEntityRefs((snapshot?.entities || []).filter((e) =>
|
||||
e?.source === "ref"
|
||||
&& !geoEntityIds.has(e.id)
|
||||
&& !linkedByWikiIds.has(e.id)
|
||||
&& e.operation !== "create"
|
||||
&& e.operation !== "update"
|
||||
&& e.operation !== "delete"
|
||||
));
|
||||
options.setWikis(snapshot?.wikis || []);
|
||||
options.setEntityWikiLinks(snapshot?.entity_wiki || []);
|
||||
options.setSnapshotEntities(sessionSnapshot?.entities || []);
|
||||
options.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
||||
options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
||||
options.setSelectedFeatureId(null);
|
||||
options.setEntityFormStatus(null);
|
||||
}, [options]);
|
||||
@@ -104,6 +87,10 @@ export function useSectionCommands(options: Options) {
|
||||
options.setEntityStatus("Chưa mở được section editor.");
|
||||
return;
|
||||
}
|
||||
if (options.pendingSaveCount <= 0) {
|
||||
options.setEntityStatus("Không có thay đổi để Commit.");
|
||||
return;
|
||||
}
|
||||
|
||||
const geometryChanges = options.editor.buildPayload();
|
||||
options.setIsSaving(true);
|
||||
@@ -113,26 +100,46 @@ export function useSectionCommands(options: Options) {
|
||||
section: options.activeSection,
|
||||
draft: options.editor.draft,
|
||||
changes: geometryChanges,
|
||||
pendingEntities: options.pendingEntityCreates,
|
||||
projectEntityRefs: options.projectEntityRefs,
|
||||
wikis: options.wikis,
|
||||
entityWikiLinks: options.entityWikiLinks,
|
||||
previousSnapshot: options.lastSectionSnapshot,
|
||||
snapshotEntities: options.snapshotEntities,
|
||||
snapshotWikis: options.snapshotWikis,
|
||||
snapshotEntityWikiLinks: options.snapshotEntityWikiLinks,
|
||||
previousSnapshot: options.baselineSnapshot,
|
||||
hasPersistedFeature: options.editor.hasPersistedFeature,
|
||||
});
|
||||
const editSummary = options.commitTitle.trim()
|
||||
|| options.commitNote.trim()
|
||||
|| `Edit ${new Date().toLocaleString()}`;
|
||||
|
||||
// Guardrail: commit payload can get large and some deployments reject/close connections for big bodies.
|
||||
// When that happens, browsers often surface it as "TypeError: Failed to fetch".
|
||||
try {
|
||||
const payloadText = JSON.stringify({ snapshot_json: snapshot, edit_summary: editSummary });
|
||||
const bytes = typeof Blob !== "undefined" ? new Blob([payloadText]).size : payloadText.length;
|
||||
const limitBytes = 3_500_000; // ~3.5MB (conservative vs common default body limits)
|
||||
if (bytes > limitBytes) {
|
||||
options.setEntityStatus(
|
||||
`Commit payload quá lớn (~${(bytes / (1024 * 1024)).toFixed(2)}MB). ` +
|
||||
`Hãy giảm bớt nội dung snapshot/changes hoặc chạy BE local với body limit lớn hơn.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// If stringify fails, let API call throw a more actionable error downstream.
|
||||
}
|
||||
|
||||
const result = await createSectionCommit(options.activeSection.id, {
|
||||
snapshot,
|
||||
edit_summary: options.commitTitle.trim()
|
||||
|| options.commitNote.trim()
|
||||
|| `Edit ${new Date().toLocaleString()}`,
|
||||
edit_summary: editSummary,
|
||||
});
|
||||
|
||||
const sessionSnapshot = toEditorSessionSnapshot(snapshot);
|
||||
options.setSectionState(result.state);
|
||||
options.setLastSectionSnapshot(snapshot);
|
||||
options.setBaselineSnapshot(sessionSnapshot);
|
||||
options.setSnapshotEntities(sessionSnapshot.entities || []);
|
||||
options.setSnapshotWikis(sessionSnapshot.wikis || []);
|
||||
options.setSnapshotEntityWikiLinks(sessionSnapshot.entity_wiki || []);
|
||||
options.setInitialData(options.editor.draft);
|
||||
options.editor.clearChanges();
|
||||
options.setPendingEntityCreates([]);
|
||||
options.setCreatedEntities([]);
|
||||
options.setCommitTitle("");
|
||||
options.setCommitNote("");
|
||||
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
|
||||
@@ -250,19 +257,30 @@ export function useSectionCommands(options: Options) {
|
||||
options.setIsSaving(true);
|
||||
options.setEntityStatus(null);
|
||||
try {
|
||||
const result = await restoreSectionCommit(options.activeSection.id, {
|
||||
commit_id: commitId,
|
||||
});
|
||||
const editorPayload = await openSectionEditor(options.activeSection.id);
|
||||
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
|
||||
options.setSectionState(result.state);
|
||||
options.setLastSectionSnapshot(snapshot);
|
||||
if (snapshot?.editor_feature_collection) {
|
||||
options.setInitialData(snapshot.editor_feature_collection);
|
||||
// FE-only restore: load snapshot from selected commit and apply to editor state.
|
||||
// Do NOT move project's head commit on backend.
|
||||
const commits = await fetchSectionCommits(options.activeSection.id);
|
||||
const target = commits.find((c: SectionCommit) => c.id === commitId) || null;
|
||||
if (!target) {
|
||||
options.setEntityStatus("Không tìm thấy commit để restore.");
|
||||
return;
|
||||
}
|
||||
options.setWikis(snapshot?.wikis || []);
|
||||
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
|
||||
options.setEntityFormStatus("Đã restore commit.");
|
||||
|
||||
const snapshot = normalizeEditorSnapshot(target.snapshot_json);
|
||||
const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null;
|
||||
const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
|
||||
|
||||
options.setBaselineSnapshot(sessionSnapshot);
|
||||
options.setInitialData(nextInitialData);
|
||||
options.setSnapshotEntities(sessionSnapshot?.entities || []);
|
||||
options.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
||||
options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
||||
options.setSelectedFeatureId(null);
|
||||
options.setEntityFormStatus(null);
|
||||
|
||||
// Refresh commits list for UI, but keep sectionState/head as-is.
|
||||
options.setSectionCommits(commits);
|
||||
options.setEntityFormStatus("Đã load snapshot từ commit (không đổi head trên BE).");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
options.setEntityStatus(`Restore thất bại: ${err.body}`);
|
||||
@@ -283,3 +301,63 @@ export function useSectionCommands(options: Options) {
|
||||
restoreCommit,
|
||||
};
|
||||
}
|
||||
|
||||
function toEditorSessionSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
|
||||
return {
|
||||
...snapshot,
|
||||
entities: toEditorSessionEntities(snapshot.entities),
|
||||
wikis: toEditorSessionWikis(snapshot.wikis),
|
||||
entity_wiki: toEditorSessionEntityWikiLinks(snapshot.entity_wiki),
|
||||
};
|
||||
}
|
||||
|
||||
function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnapshot[] {
|
||||
const rows = Array.isArray(input) ? input : [];
|
||||
return rows
|
||||
.filter((e) => e && (typeof e.id === "string" || typeof e.id === "number"))
|
||||
.map((e) => {
|
||||
const { operation: _op, ...rest } = e;
|
||||
const id = String(e.id);
|
||||
const source: EntitySnapshot["source"] = e.source === "inline" ? "inline" : "ref";
|
||||
return {
|
||||
...(rest as Omit<EntitySnapshot, "id" | "source" | "operation">),
|
||||
id,
|
||||
source,
|
||||
operation: "reference",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function toEditorSessionWikis(input: EditorSnapshot["wikis"]): WikiSnapshot[] {
|
||||
const rows = Array.isArray(input) ? input : [];
|
||||
return rows
|
||||
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
|
||||
.map((w) => {
|
||||
const { operation: _op, ...rest } = w;
|
||||
const source: WikiSnapshot["source"] = w.source === "inline" ? "inline" : "ref";
|
||||
return {
|
||||
...(rest as Omit<WikiSnapshot, "source" | "operation">),
|
||||
source,
|
||||
operation: "reference",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function toEditorSessionEntityWikiLinks(input: EditorSnapshot["entity_wiki"]): EntityWikiLinkSnapshot[] {
|
||||
const rows = Array.isArray(input) ? input : [];
|
||||
const deduped = new globalThis.Map<string, EntityWikiLinkSnapshot>();
|
||||
for (const row of rows) {
|
||||
if (!row || typeof row.entity_id !== "string" || typeof row.wiki_id !== "string") continue;
|
||||
if (row.operation === "delete") continue;
|
||||
const entity_id = row.entity_id.trim();
|
||||
const wiki_id = row.wiki_id.trim();
|
||||
if (!entity_id || !wiki_id) continue;
|
||||
const key = `${entity_id}::${wiki_id}`;
|
||||
deduped.set(key, { entity_id, wiki_id, operation: "binding" });
|
||||
}
|
||||
return Array.from(deduped.values()).sort((a, b) => {
|
||||
const e = a.entity_id.localeCompare(b.entity_id);
|
||||
if (e !== 0) return e;
|
||||
return a.wiki_id.localeCompare(b.wiki_id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,11 +16,11 @@ export type TimelineRange = {
|
||||
|
||||
export type EntityFormState = {
|
||||
name: string;
|
||||
slug: string;
|
||||
type_id: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type GeometryMetaFormState = {
|
||||
type_key: string;
|
||||
time_start: string;
|
||||
time_end: string;
|
||||
binding: string;
|
||||
@@ -29,16 +29,13 @@ export type GeometryMetaFormState = {
|
||||
export type PendingEntityCreate = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string | null;
|
||||
type_id: string;
|
||||
description: string | null;
|
||||
status: number;
|
||||
};
|
||||
|
||||
export type CreatedEntitySummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
type_id?: string | null;
|
||||
};
|
||||
|
||||
export type GeometryPreset = EntityGeometryPreset;
|
||||
|
||||
|
||||
@@ -2,23 +2,16 @@ import { useState } from "react";
|
||||
import type { Entity } from "@/uhm/types/entities";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { FeatureId } from "@/uhm/types/geo";
|
||||
import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
|
||||
import type {
|
||||
CreatedEntitySummary,
|
||||
EntityFormState,
|
||||
GeometryMetaFormState,
|
||||
PendingEntityCreate,
|
||||
} from "@/uhm/lib/editor/session/sessionTypes";
|
||||
|
||||
export function useEntitySessionState() {
|
||||
// Entities đã persisted từ backend (dùng cho search/binding).
|
||||
const [persistedEntities, setPersistedEntities] = useState<Entity[]>([]);
|
||||
// Entities được "pin" vào project dưới dạng reference (không cần chọn geometry).
|
||||
const [projectEntityRefs, setProjectEntityRefs] = useState<EntitySnapshot[]>([]);
|
||||
// Entities tạo mới trong phiên nhưng chưa commit lên backend.
|
||||
const [pendingEntityCreates, setPendingEntityCreates] = useState<PendingEntityCreate[]>([]);
|
||||
// Tóm tắt entities đã tạo (để hiển thị nhanh ở sidebar).
|
||||
const [createdEntities, setCreatedEntities] = useState<CreatedEntitySummary[]>([]);
|
||||
// Entity catalog loaded from backend (global list, used for search/lookup).
|
||||
const [entityCatalog, setEntityCatalog] = useState<Entity[]>([]);
|
||||
// Snapshot entity store for the current editor session (single source of truth for snapshot.entities).
|
||||
const [snapshotEntities, setSnapshotEntities] = useState<EntitySnapshot[]>([]);
|
||||
// Thông báo trạng thái/lỗi liên quan entity/session.
|
||||
const [entityStatus, setEntityStatus] = useState<string | null>(null);
|
||||
// Feature đang được chọn để thao tác bind entities/metadata.
|
||||
@@ -26,13 +19,13 @@ export function useEntitySessionState() {
|
||||
// Form tạo entity mới (độc lập).
|
||||
const [entityForm, setEntityForm] = useState<EntityFormState>({
|
||||
name: "",
|
||||
slug: "",
|
||||
type_id: DEFAULT_ENTITY_TYPE_ID,
|
||||
description: "",
|
||||
});
|
||||
// Danh sách entity IDs đang chọn để bind vào geometry hiện tại.
|
||||
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
|
||||
// Form metadata geometry (time range + binding ids).
|
||||
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
|
||||
type_key: "",
|
||||
time_start: "",
|
||||
time_end: "",
|
||||
binding: "",
|
||||
@@ -51,14 +44,10 @@ export function useEntitySessionState() {
|
||||
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
|
||||
|
||||
return {
|
||||
persistedEntities,
|
||||
setPersistedEntities,
|
||||
projectEntityRefs,
|
||||
setProjectEntityRefs,
|
||||
pendingEntityCreates,
|
||||
setPendingEntityCreates,
|
||||
createdEntities,
|
||||
setCreatedEntities,
|
||||
entityCatalog,
|
||||
setEntityCatalog,
|
||||
snapshotEntities,
|
||||
setSnapshotEntities,
|
||||
entityStatus,
|
||||
setEntityStatus,
|
||||
selectedFeatureId,
|
||||
|
||||
@@ -51,8 +51,8 @@ export function useSectionSessionState(options: Options) {
|
||||
const [sectionState, setSectionState] = useState<SectionState | null>(null);
|
||||
// Danh sách commits của section đang mở.
|
||||
const [sectionCommits, setSectionCommits] = useState<SectionCommit[]>([]);
|
||||
// Snapshot gần nhất đã load (để build snapshot diff/metadata).
|
||||
const [lastSectionSnapshot, setLastSectionSnapshot] = useState<EditorSnapshot | null>(null);
|
||||
// Baseline snapshot currently loaded for this editor session.
|
||||
const [baselineSnapshot, setBaselineSnapshot] = useState<EditorSnapshot | null>(null);
|
||||
|
||||
return {
|
||||
isSaving,
|
||||
@@ -79,7 +79,7 @@ export function useSectionSessionState(options: Options) {
|
||||
setSectionState,
|
||||
sectionCommits,
|
||||
setSectionCommits,
|
||||
lastSectionSnapshot,
|
||||
setLastSectionSnapshot,
|
||||
baselineSnapshot,
|
||||
setBaselineSnapshot,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||
|
||||
export function useWikiSessionState() {
|
||||
const [wikis, setWikis] = useState<WikiSnapshot[]>([]);
|
||||
const [entityWikiLinks, setEntityWikiLinks] = useState<EntityWikiLinkSnapshot[]>([]);
|
||||
return { wikis, setWikis, entityWikiLinks, setEntityWikiLinks };
|
||||
const [snapshotWikis, setSnapshotWikis] = useState<WikiSnapshot[]>([]);
|
||||
const [snapshotEntityWikiLinks, setSnapshotEntityWikiLinks] = useState<EntityWikiLinkSnapshot[]>([]);
|
||||
return { snapshotWikis, setSnapshotWikis, snapshotEntityWikiLinks, setSnapshotEntityWikiLinks };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
|
||||
import { typeKeyToGeoTypeCode } from "@/uhm/lib/geoTypeMap";
|
||||
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||
import type { PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
||||
import type { EditorSnapshot, Section } from "@/uhm/types/sections";
|
||||
@@ -42,17 +42,21 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||
.filter(isRecord)
|
||||
.map((e) => {
|
||||
const id = getStringId(e.id);
|
||||
const op = typeof e.operation === "string" ? e.operation : undefined;
|
||||
const opRaw = typeof e.operation === "string" ? e.operation : undefined;
|
||||
const operation: EntitySnapshot["operation"] =
|
||||
opRaw === "delete" ? "delete" : "reference";
|
||||
const existingSource = e.source === "inline" || e.source === "ref" ? e.source : undefined;
|
||||
const refId = getRefId(e.ref);
|
||||
const source: "inline" | "ref" = existingSource || (refId || op === "reference" ? "ref" : "inline");
|
||||
const source: "inline" | "ref" =
|
||||
existingSource || (refId || opRaw === "reference" ? "ref" : "inline");
|
||||
const rest: UnknownRecord = { ...e };
|
||||
delete rest.ref;
|
||||
|
||||
return {
|
||||
...(rest as unknown as Omit<EntitySnapshot, "id" | "source">),
|
||||
...(rest as unknown as Omit<EntitySnapshot, "id" | "source" | "operation">),
|
||||
id,
|
||||
source,
|
||||
operation,
|
||||
};
|
||||
})
|
||||
: undefined;
|
||||
@@ -63,6 +67,9 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||
.filter(isRecord)
|
||||
.map((g) => {
|
||||
const id = getStringId(g.id);
|
||||
const opRaw = typeof g.operation === "string" ? g.operation : undefined;
|
||||
const operation: GeometrySnapshot["operation"] =
|
||||
opRaw === "delete" ? "delete" : "reference";
|
||||
const existingSource = g.source === "inline" || g.source === "ref" ? g.source : undefined;
|
||||
const refId = getRefId(g.ref);
|
||||
const hasInlineGeometry = "draw_geometry" in g || "geometry" in g;
|
||||
@@ -71,9 +78,10 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||
delete rest.ref;
|
||||
|
||||
return {
|
||||
...(rest as unknown as Omit<GeometrySnapshot, "id" | "source">),
|
||||
...(rest as unknown as Omit<GeometrySnapshot, "id" | "source" | "operation">),
|
||||
id,
|
||||
source,
|
||||
operation,
|
||||
};
|
||||
})
|
||||
: undefined;
|
||||
@@ -84,17 +92,21 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||
.filter(isRecord)
|
||||
.map((w) => {
|
||||
const id = typeof w.id === "string" ? w.id : "";
|
||||
const op = typeof w.operation === "string" ? w.operation : undefined;
|
||||
const opRaw = typeof w.operation === "string" ? w.operation : undefined;
|
||||
const operation: WikiSnapshot["operation"] =
|
||||
opRaw === "delete" ? "delete" : "reference";
|
||||
const existingSource = w.source === "inline" || w.source === "ref" ? w.source : undefined;
|
||||
const refId = getRefId(w.ref);
|
||||
const source: "inline" | "ref" = existingSource || (refId || op === "reference" ? "ref" : "inline");
|
||||
const source: "inline" | "ref" =
|
||||
existingSource || (refId || opRaw === "reference" ? "ref" : "inline");
|
||||
const rest: UnknownRecord = { ...w };
|
||||
delete rest.ref;
|
||||
|
||||
return {
|
||||
...(rest as unknown as Omit<WikiSnapshot, "id" | "source">),
|
||||
...(rest as unknown as Omit<WikiSnapshot, "id" | "source" | "operation">),
|
||||
id,
|
||||
source,
|
||||
operation,
|
||||
};
|
||||
})
|
||||
: undefined;
|
||||
@@ -147,8 +159,14 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||
: typeof r.is_deleted === "boolean"
|
||||
? r.is_deleted
|
||||
: false;
|
||||
const operation: "reference" | "delete" =
|
||||
opRaw === "delete" ? "delete" : opRaw === "reference" ? "reference" : isDeleted ? "delete" : "reference";
|
||||
const operation: "binding" | "delete" =
|
||||
opRaw === "delete"
|
||||
? "delete"
|
||||
: opRaw === "binding" || opRaw === "reference"
|
||||
? "binding"
|
||||
: isDeleted
|
||||
? "delete"
|
||||
: "binding";
|
||||
return { entity_id, wiki_id, operation };
|
||||
})
|
||||
.filter((r) => r.entity_id.length > 0 && r.wiki_id.length > 0)
|
||||
@@ -194,10 +212,9 @@ export function buildEditorSnapshot(options: {
|
||||
section: Section;
|
||||
draft: FeatureCollection;
|
||||
changes: Change[];
|
||||
pendingEntities: PendingEntityCreate[];
|
||||
projectEntityRefs: EntitySnapshot[];
|
||||
wikis: WikiSnapshot[];
|
||||
entityWikiLinks: EntityWikiLinkSnapshot[];
|
||||
snapshotEntities: EntitySnapshot[];
|
||||
snapshotWikis: WikiSnapshot[];
|
||||
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
|
||||
previousSnapshot: EditorSnapshot | null;
|
||||
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
||||
}): EditorSnapshot {
|
||||
@@ -225,40 +242,58 @@ export function buildEditorSnapshot(options: {
|
||||
if (id && operation) previousGeometryOps.set(id, operation);
|
||||
}
|
||||
|
||||
const pendingEntityIds = new Set(options.pendingEntities.map((entity) => entity.id));
|
||||
const entityRows = new globalThis.Map<string, EntitySnapshot>();
|
||||
for (const entity of options.pendingEntities) {
|
||||
entityRows.set(entity.id, {
|
||||
id: entity.id,
|
||||
|
||||
// Persist inline entity records across commits even when they're not currently bound.
|
||||
// Without this, "create entity" can disappear on the next commit unless the entity is referenced
|
||||
// by geometry_entity/entity_wiki or pinned via projectEntityRefs.
|
||||
for (const prev of options.previousSnapshot?.entities || []) {
|
||||
if (!prev) continue;
|
||||
const id = typeof prev.id === "string" || typeof prev.id === "number" ? String(prev.id) : "";
|
||||
if (!id || entityRows.has(id)) continue;
|
||||
if (prev.operation === "delete") continue;
|
||||
if (prev.source !== "inline") continue;
|
||||
// Carry forward as current-state inline entity; operation is a per-commit delta signal.
|
||||
const cloned = JSON.parse(JSON.stringify(prev)) as EntitySnapshot;
|
||||
const { operation: _op, ...rest } = cloned;
|
||||
entityRows.set(id, {
|
||||
...rest,
|
||||
id,
|
||||
source: "inline",
|
||||
operation: "create",
|
||||
name: entity.name,
|
||||
slug: entity.slug,
|
||||
description: null,
|
||||
type_id: entity.type_id,
|
||||
status: entity.status,
|
||||
operation: "reference",
|
||||
});
|
||||
}
|
||||
|
||||
for (const ref of options.projectEntityRefs || []) {
|
||||
const id = typeof ref?.id === "string" || typeof ref?.id === "number" ? String(ref.id) : "";
|
||||
if (!id || entityRows.has(id)) continue;
|
||||
const cloned = JSON.parse(JSON.stringify(ref)) as EntitySnapshot;
|
||||
for (const row of options.snapshotEntities || []) {
|
||||
if (!row) continue;
|
||||
const id = typeof row.id === "string" || typeof row.id === "number" ? String(row.id) : "";
|
||||
if (!id) continue;
|
||||
const cloned = JSON.parse(JSON.stringify(row)) as EntitySnapshot;
|
||||
const name =
|
||||
typeof cloned?.name === "string" && cloned.name.trim().length
|
||||
? cloned.name.trim()
|
||||
: id;
|
||||
const source: "inline" | "ref" = cloned.source === "inline" ? "inline" : "ref";
|
||||
entityRows.set(id, {
|
||||
...cloned,
|
||||
id,
|
||||
source: "ref",
|
||||
source,
|
||||
name,
|
||||
operation: cloned.operation ?? "reference",
|
||||
});
|
||||
}
|
||||
|
||||
// Entities referenced by wiki links should be present as "reference" too.
|
||||
for (const link of options.entityWikiLinks || []) {
|
||||
for (const link of options.snapshotEntityWikiLinks || []) {
|
||||
const id = typeof link?.entity_id === "string" ? link.entity_id : "";
|
||||
if (!id || entityRows.has(id)) continue;
|
||||
entityRows.set(id, {
|
||||
id,
|
||||
source: "ref",
|
||||
operation: "reference",
|
||||
name: id,
|
||||
slug: null,
|
||||
description: null,
|
||||
status: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -272,7 +307,6 @@ export function buildEditorSnapshot(options: {
|
||||
name: entityId,
|
||||
slug: null,
|
||||
description: null,
|
||||
type_id: feature.properties.type || DEFAULT_ENTITY_TYPE_ID,
|
||||
status: 1,
|
||||
});
|
||||
}
|
||||
@@ -294,11 +328,14 @@ export function buildEditorSnapshot(options: {
|
||||
? "update"
|
||||
: undefined;
|
||||
const bbox = getFeatureBBox(feature);
|
||||
const typeKey = feature.properties.type || getDefaultTypeIdForFeature(feature);
|
||||
const typeCode = typeKeyToGeoTypeCode(typeKey);
|
||||
return {
|
||||
id,
|
||||
operation,
|
||||
source: "inline",
|
||||
type: feature.properties.type || getDefaultTypeIdForFeature(feature),
|
||||
// BE currently expects geometries[].type as a string. We send the geo_type SMALLINT code as a string.
|
||||
type: String(typeCode ?? 0),
|
||||
draw_geometry: feature.geometry,
|
||||
binding: normalizeFeatureBindingIds(feature),
|
||||
time_start: feature.properties.time_start ?? null,
|
||||
@@ -322,11 +359,12 @@ export function buildEditorSnapshot(options: {
|
||||
});
|
||||
}
|
||||
|
||||
const geometryEntity: GeometryEntitySnapshot[] = options.draft.features.flatMap((feature) => {
|
||||
const geometryEntityRaw: GeometryEntitySnapshot[] = options.draft.features.flatMap((feature) => {
|
||||
const geometry_id = String(feature.properties.id);
|
||||
const entityIds = normalizeFeatureEntityIds(feature);
|
||||
return entityIds.map((entity_id) => ({ geometry_id, entity_id }));
|
||||
});
|
||||
const geometryEntity = dedupeAndSortGeometryEntity(geometryEntityRaw);
|
||||
|
||||
// Persist snapshot without denormalized entity fields on features (many-to-many lives in geometry_entity[]).
|
||||
const draftForSnapshot = JSON.parse(JSON.stringify(options.draft)) as FeatureCollection;
|
||||
@@ -350,8 +388,17 @@ export function buildEditorSnapshot(options: {
|
||||
// Operation semantics:
|
||||
// - create/update/delete: this commit changes the wiki itself
|
||||
// - reference: this wiki is a ref used for linking (entity<->wiki), not a modification
|
||||
const wikis: WikiSnapshot[] = (options.wikis || [])
|
||||
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
|
||||
const wikis: WikiSnapshot[] = (options.snapshotWikis || [])
|
||||
.filter((w) => {
|
||||
if (!w || typeof w.id !== "string" || w.id.trim().length === 0) return false;
|
||||
if (w.source === "ref") return true;
|
||||
// Keep explicit operations (e.g. delete) even if content is empty.
|
||||
if (w.operation === "create" || w.operation === "update" || w.operation === "delete") return true;
|
||||
// Inline wiki with no content: don't persist it (treat as not written).
|
||||
const title = typeof w.title === "string" ? w.title.trim() : "";
|
||||
const doc = typeof w.doc === "string" ? w.doc.trim() : "";
|
||||
return title.length > 0 || (w.doc !== null && doc.length > 0);
|
||||
})
|
||||
.map((w) => {
|
||||
const prev = previousWikis.get(w.id) || null;
|
||||
const cloned = JSON.parse(JSON.stringify(w)) as WikiSnapshot;
|
||||
@@ -388,28 +435,66 @@ export function buildEditorSnapshot(options: {
|
||||
return cloned;
|
||||
});
|
||||
|
||||
const entityWikis: EntityWikiLinkSnapshot[] = (options.entityWikiLinks || [])
|
||||
const entityWikisRaw: EntityWikiLinkSnapshot[] = (options.snapshotEntityWikiLinks || [])
|
||||
.filter((l) => l && typeof l.entity_id === "string" && typeof l.wiki_id === "string")
|
||||
.map((l) => ({
|
||||
entity_id: l.entity_id,
|
||||
wiki_id: l.wiki_id,
|
||||
operation: l.operation === "delete" ? "delete" : "reference",
|
||||
operation: l.operation === "delete" ? "delete" : "binding",
|
||||
}));
|
||||
const entityWikis = dedupeAndSortEntityWiki(entityWikisRaw);
|
||||
|
||||
return {
|
||||
editor_feature_collection: draftForSnapshot,
|
||||
entities: Array.from(entityRows.values()).map((entity) => {
|
||||
const id = String(entity.id || "");
|
||||
if (pendingEntityIds.has(id)) return entity;
|
||||
return entity;
|
||||
}),
|
||||
geometries,
|
||||
entities: Array.from(entityRows.values()).sort((a, b) => String(a.id).localeCompare(String(b.id))),
|
||||
geometries: geometries.slice().sort((a, b) => String(a.id).localeCompare(String(b.id))),
|
||||
geometry_entity: geometryEntity,
|
||||
wikis,
|
||||
wikis: wikis.slice().sort((a, b) => a.id.localeCompare(b.id)),
|
||||
entity_wiki: entityWikis,
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeAndSortGeometryEntity(rows: GeometryEntitySnapshot[]): GeometryEntitySnapshot[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: GeometryEntitySnapshot[] = [];
|
||||
for (const row of rows) {
|
||||
const geometry_id = typeof row.geometry_id === "string" ? row.geometry_id : "";
|
||||
const entity_id = typeof row.entity_id === "string" ? row.entity_id : "";
|
||||
if (!geometry_id || !entity_id) continue;
|
||||
const key = `${geometry_id}::${entity_id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push({ ...row, geometry_id, entity_id });
|
||||
}
|
||||
deduped.sort((a, b) => {
|
||||
const g = a.geometry_id.localeCompare(b.geometry_id);
|
||||
if (g !== 0) return g;
|
||||
return a.entity_id.localeCompare(b.entity_id);
|
||||
});
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function dedupeAndSortEntityWiki(rows: EntityWikiLinkSnapshot[]): EntityWikiLinkSnapshot[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: EntityWikiLinkSnapshot[] = [];
|
||||
for (const row of rows) {
|
||||
const entity_id = typeof row.entity_id === "string" ? row.entity_id : "";
|
||||
const wiki_id = typeof row.wiki_id === "string" ? row.wiki_id : "";
|
||||
if (!entity_id || !wiki_id) continue;
|
||||
const operation = row.operation === "delete" ? "delete" : "binding";
|
||||
const key = `${entity_id}::${wiki_id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push({ entity_id, wiki_id, operation });
|
||||
}
|
||||
deduped.sort((a, b) => {
|
||||
const e = a.entity_id.localeCompare(b.entity_id);
|
||||
if (e !== 0) return e;
|
||||
return a.wiki_id.localeCompare(b.wiki_id);
|
||||
});
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export function getDefaultTypeIdForFeature(feature: Feature): string {
|
||||
const preset = feature.properties.geometry_preset;
|
||||
if (preset === "line") return "defense_line";
|
||||
|
||||
Reference in New Issue
Block a user