refactor: simplify timeline stage creation form by removing redundant validation logic and improving layout
Build and Release / release (push) Successful in 36s

This commit is contained in:
taDuc
2026-05-25 08:19:53 +07:00
parent f38ae2c288
commit de91f8129e
6 changed files with 266 additions and 329 deletions
+1
View File
@@ -1801,6 +1801,7 @@ function EditorPageContent() {
type: "Feature", type: "Feature",
properties: { properties: {
id: geoId, id: geoId,
source: "ref",
type: typeKey, type: typeKey,
time_start: normalizeTimelineYearValue(geo.time_start), time_start: normalizeTimelineYearValue(geo.time_start),
time_end: normalizeTimelineYearValue(geo.time_end), time_end: normalizeTimelineYearValue(geo.time_end),
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { import type {
BattleReplay, BattleReplay,
GeoFunctionName, GeoFunctionName,
@@ -43,46 +43,6 @@ type AnyStepAction =
| ReplayAction<GeoFunctionName> | ReplayAction<GeoFunctionName>
| ReplayAction<NarrativeFunctionName>; | ReplayAction<NarrativeFunctionName>;
function validateReplayTimeFormat(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
// 1. Check DD/MM/YYYY
const dmyRegex = /^(\d{2})\/(\d{2})\/(-?\d{4})$/;
const dmyMatch = trimmed.match(dmyRegex);
if (dmyMatch) {
const day = parseInt(dmyMatch[1], 10);
const month = parseInt(dmyMatch[2], 10);
const year = parseInt(dmyMatch[3], 10);
if (month < 1 || month > 12) return false;
if (day < 1 || day > 31) return false;
if (month === 2) {
const isLeap = (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);
if (day > (isLeap ? 29 : 28)) return false;
} else if ([4, 6, 9, 11].includes(month)) {
if (day > 30) return false;
}
return true;
}
// 2. Check MM/YYYY
const myRegex = /^(\d{2})\/(-?\d{4})$/;
const myMatch = trimmed.match(myRegex);
if (myMatch) {
const month = parseInt(myMatch[1], 10);
if (month < 1 || month > 12) return false;
return true;
}
// 3. Check YYYY
const yRegex = /^(-?\d{4})$/;
if (yRegex.test(trimmed)) {
return true;
}
return false;
}
type StageFormState = { type StageFormState = {
title: string; title: string;
detail_time_start: string; detail_time_start: string;
@@ -143,13 +103,6 @@ export default function ReplayTimelineSidebar({
detail_time_stop: "", detail_time_stop: "",
}); });
const [createStagePanelKey, setCreateStagePanelKey] = useState(0); const [createStagePanelKey, setCreateStagePanelKey] = useState(0);
const isStartValid = validateReplayTimeFormat(createStageForm.detail_time_start);
const isStopValid = validateReplayTimeFormat(createStageForm.detail_time_stop);
const isCreateFormValid = createStageForm.title.trim() !== "" && isStartValid && isStopValid;
const showStartError = createStageForm.detail_time_start.length > 0 && !isStartValid;
const showStopError = createStageForm.detail_time_stop.length > 0 && !isStopValid;
const [openWeightEditorKey, setOpenWeightEditorKey] = useState<string | null>(null); const [openWeightEditorKey, setOpenWeightEditorKey] = useState<string | null>(null);
const [openActionDetailKey, setOpenActionDetailKey] = useState<string | null>(null); const [openActionDetailKey, setOpenActionDetailKey] = useState<string | null>(null);
@@ -401,26 +354,7 @@ export default function ReplayTimelineSidebar({
); );
}; };
const handleDuplicateAction = (
stageId: number,
stepIndex: number,
groupKey: ActionGroupKey,
actionIndex: number,
actionTitle: string
) => {
onMutateReplay(
`Replay: nhân bản ${actionTitle} ở step ${stepIndex + 1} của stage #${stageId}`,
(draftReplay) => {
const stage = draftReplay.detail.find((item) => item.id === stageId);
if (!stage || stepIndex < 0 || stepIndex >= stage.steps.length) return;
const step = stage.steps[stepIndex];
const actions = [...getStepActionGroup(step, groupKey)];
if (actionIndex < 0 || actionIndex >= actions.length) return;
actions.splice(actionIndex + 1, 0, cloneReplayAction(actions[actionIndex]) as AnyStepAction);
setStepActionGroup(step, groupKey, actions);
}
);
};
const handleUpdateActionParams = ( const handleUpdateActionParams = (
stageId: number, stageId: number,
@@ -603,10 +537,9 @@ export default function ReplayTimelineSidebar({
onChange={(event) => onChange={(event) =>
setCreateStageForm((prev) => ({ ...prev, title: event.target.value })) setCreateStageForm((prev) => ({ ...prev, title: event.target.value }))
} }
placeholder="Title (bắt buộc)" placeholder="Title"
style={inputStyle} style={inputStyle}
/> />
<div>
<input <input
value={createStageForm.detail_time_start} value={createStageForm.detail_time_start}
onChange={(event) => onChange={(event) =>
@@ -615,19 +548,14 @@ export default function ReplayTimelineSidebar({
detail_time_start: event.target.value, detail_time_start: event.target.value,
})) }))
} }
placeholder="Thời gian bắt đầu (detail_time_start)" placeholder="detail_time_start (DD/MM/YYYY hoặc MM/YYYY hoặc YYYY)"
style={{ style={{
...inputStyle, ...inputStyle,
borderColor: showStartError ? "#ef4444" : "#334155", border: createStageForm.detail_time_start && !validateReplayTimeFormat(createStageForm.detail_time_start)
? "1px solid #ef4444"
: undefined,
}} }}
/> />
{showStartError ? (
<div style={{ color: "#ef4444", fontSize: 10, marginTop: 3 }}>
Đnh dạng không hợp lệ (DD/MM/YYYY, MM/YYYY, hoặc YYYY)
</div>
) : null}
</div>
<div>
<input <input
value={createStageForm.detail_time_stop} value={createStageForm.detail_time_stop}
onChange={(event) => onChange={(event) =>
@@ -636,30 +564,39 @@ export default function ReplayTimelineSidebar({
detail_time_stop: event.target.value, detail_time_stop: event.target.value,
})) }))
} }
placeholder="Thời gian kết thúc (detail_time_stop)" placeholder="detail_time_stop (DD/MM/YYYY hoặc MM/YYYY hoặc YYYY)"
style={{ style={{
...inputStyle, ...inputStyle,
borderColor: showStopError ? "#ef4444" : "#334155", border: createStageForm.detail_time_stop && !validateReplayTimeFormat(createStageForm.detail_time_stop)
? "1px solid #ef4444"
: undefined,
}} }}
/> />
{showStopError ? ( <div style={{ fontSize: 10, color: "#94a3b8", lineHeight: 1.3 }}>
<div style={{ color: "#ef4444", fontSize: 10, marginTop: 3 }}> * Đnh dạng bắt buộc: <strong>ngày/tháng/năm</strong> (00/00/0000), <strong>tháng/năm</strong> (00/0000) hoặc <strong>năm</strong> (0000).
Đnh dạng không hợp lệ (DD/MM/YYYY, MM/YYYY, hoặc YYYY)
</div>
) : null}
</div>
<div style={{ fontSize: 10, color: "#94a3b8", lineHeight: "1.4" }}>
Cấu trúc bắt buộc: <strong>DD/MM/YYYY</strong>, <strong>MM/YYYY</strong>, hoặc <strong>YYYY</strong>.
</div> </div>
<button <button
type="button" type="button"
disabled={!isCreateFormValid}
onClick={handleCreateStage} onClick={handleCreateStage}
disabled={
!createStageForm.title.trim() ||
!validateReplayTimeFormat(createStageForm.detail_time_start) ||
!validateReplayTimeFormat(createStageForm.detail_time_stop)
}
style={{ style={{
...buttonStyle, ...buttonStyle,
background: isCreateFormValid ? "#1d4ed8" : "#1e293b", background:
color: isCreateFormValid ? "white" : "#64748b", createStageForm.title.trim() &&
cursor: isCreateFormValid ? "pointer" : "not-allowed", validateReplayTimeFormat(createStageForm.detail_time_start) &&
validateReplayTimeFormat(createStageForm.detail_time_stop)
? "#1d4ed8"
: "#475569",
cursor:
createStageForm.title.trim() &&
validateReplayTimeFormat(createStageForm.detail_time_start) &&
validateReplayTimeFormat(createStageForm.detail_time_stop)
? "pointer"
: "not-allowed",
border: "none", border: "none",
}} }}
> >
@@ -928,22 +865,7 @@ export default function ReplayTimelineSidebar({
</span> </span>
</div> </div>
</button> </button>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, auto)", gap: 4 }}> <div style={{ display: "grid", gridTemplateColumns: "auto", gap: 4 }}>
<button
type="button"
onClick={() =>
handleDuplicateAction(
stage.id,
stepIndex,
entry.groupKey,
entry.actionIndex,
entry.title
)
}
style={actionButtonStyle(false, "#0f766e")}
>
Copy
</button>
<button <button
type="button" type="button"
onClick={() => onClick={() =>
@@ -1120,23 +1042,13 @@ function StageMetadataEditor({
detail_time_stop: stage.detail_time_stop || "", detail_time_stop: stage.detail_time_stop || "",
}); });
useEffect(() => {
setForm({
title: stage.title || "",
detail_time_start: stage.detail_time_start || "",
detail_time_stop: stage.detail_time_stop || "",
});
}, [stage]);
const isStartValid = validateReplayTimeFormat(form.detail_time_start); const isStartValid = validateReplayTimeFormat(form.detail_time_start);
const isStopValid = validateReplayTimeFormat(form.detail_time_stop); const isStopValid = validateReplayTimeFormat(form.detail_time_stop);
const isEditFormValid = form.title.trim() !== "" && isStartValid && isStopValid; const isTitleValid = form.title.trim().length > 0;
const isFormValid = isStartValid && isStopValid && isTitleValid;
const showStartError = form.detail_time_start.length > 0 && !isStartValid;
const showStopError = form.detail_time_stop.length > 0 && !isStopValid;
const handleApplyStageMetadata = () => { const handleApplyStageMetadata = () => {
if (!isEditFormValid) return; if (!isFormValid) return;
onMutateReplay(`Replay: cập nhật stage #${stage.id}`, (draftReplay) => { onMutateReplay(`Replay: cập nhật stage #${stage.id}`, (draftReplay) => {
const targetStage = draftReplay.detail.find((item) => item.id === stage.id); const targetStage = draftReplay.detail.find((item) => item.id === stage.id);
if (!targetStage) return; if (!targetStage) return;
@@ -1154,10 +1066,9 @@ function StageMetadataEditor({
onChange={(event) => onChange={(event) =>
setForm((prev) => ({ ...prev, title: event.target.value })) setForm((prev) => ({ ...prev, title: event.target.value }))
} }
placeholder="Title (bắt buộc)" placeholder="Title"
style={inputStyle} style={inputStyle}
/> />
<div>
<input <input
value={form.detail_time_start} value={form.detail_time_start}
onChange={(event) => onChange={(event) =>
@@ -1166,19 +1077,12 @@ function StageMetadataEditor({
detail_time_start: event.target.value, detail_time_start: event.target.value,
})) }))
} }
placeholder="Thời gian bắt đầu (detail_time_start)" placeholder="detail_time_start (DD/MM/YYYY hoặc MM/YYYY hoặc YYYY)"
style={{ style={{
...inputStyle, ...inputStyle,
borderColor: showStartError ? "#ef4444" : "#334155", border: form.detail_time_start && !isStartValid ? "1px solid #ef4444" : undefined,
}} }}
/> />
{showStartError ? (
<div style={{ color: "#ef4444", fontSize: 10, marginTop: 3 }}>
Đnh dạng không hợp lệ (DD/MM/YYYY, MM/YYYY, hoặc YYYY)
</div>
) : null}
</div>
<div>
<input <input
value={form.detail_time_stop} value={form.detail_time_stop}
onChange={(event) => onChange={(event) =>
@@ -1187,30 +1091,23 @@ function StageMetadataEditor({
detail_time_stop: event.target.value, detail_time_stop: event.target.value,
})) }))
} }
placeholder="Thời gian kết thúc (detail_time_stop)" placeholder="detail_time_stop (DD/MM/YYYY hoặc MM/YYYY hoặc YYYY)"
style={{ style={{
...inputStyle, ...inputStyle,
borderColor: showStopError ? "#ef4444" : "#334155", border: form.detail_time_stop && !isStopValid ? "1px solid #ef4444" : undefined,
}} }}
/> />
{showStopError ? ( <div style={{ fontSize: 10, color: "#94a3b8", lineHeight: 1.3 }}>
<div style={{ color: "#ef4444", fontSize: 10, marginTop: 3 }}> * Đnh dạng bắt buộc: <strong>ngày/tháng/năm</strong> (00/00/0000), <strong>tháng/năm</strong> (00/0000) hoặc <strong>năm</strong> (0000).
Đnh dạng không hợp lệ (DD/MM/YYYY, MM/YYYY, hoặc YYYY)
</div>
) : null}
</div>
<div style={{ fontSize: 10, color: "#94a3b8", lineHeight: "1.4" }}>
Cấu trúc bắt buộc: <strong>DD/MM/YYYY</strong>, <strong>MM/YYYY</strong>, hoặc <strong>YYYY</strong>.
</div> </div>
<button <button
type="button" type="button"
disabled={!isEditFormValid}
onClick={handleApplyStageMetadata} onClick={handleApplyStageMetadata}
disabled={!isFormValid}
style={{ style={{
...buttonStyle, ...buttonStyle,
background: isEditFormValid ? "#0f766e" : "#1e293b", background: isFormValid ? "#0f766e" : "#475569",
color: isEditFormValid ? "white" : "#64748b", cursor: isFormValid ? "pointer" : "not-allowed",
cursor: isEditFormValid ? "pointer" : "not-allowed",
border: "none", border: "none",
}} }}
> >
@@ -1788,3 +1685,34 @@ function truncateText(value: string, maxLength: number) {
? `${value.slice(0, Math.max(0, maxLength - 1))}` ? `${value.slice(0, Math.max(0, maxLength - 1))}`
: value; : value;
} }
export function validateReplayTimeFormat(value: string): boolean {
const val = value.trim();
if (!val) return false;
// YYYY (e.g. 1945)
if (/^\d{4}$/.test(val)) {
return true;
}
// MM/YYYY (e.g. 05/1945)
if (/^(0[1-9]|1[0-2])\/\d{4}$/.test(val)) {
return true;
}
// DD/MM/YYYY (e.g. 15/05/1945)
if (/^(0[1-9]|[12]\d|3[01])\/(0[1-9]|1[0-2])\/\d{4}$/.test(val)) {
const parts = val.split("/");
const d = parseInt(parts[0], 10);
const m = parseInt(parts[1], 10);
const y = parseInt(parts[2], 10);
const monthLengths = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
if ((y % 4 === 0 && y % 100 !== 0) || y % 400 === 0) {
monthLengths[1] = 29;
}
return d <= monthLengths[m - 1];
}
return false;
}
+1
View File
@@ -43,6 +43,7 @@ export type FeatureId = string | number;
export type FeatureProperties = { export type FeatureProperties = {
id: FeatureId; id: FeatureId;
source?: SnapshotSource;
type?: string | null; type?: string | null;
geometry_preset?: GeometryPreset | null; geometry_preset?: GeometryPreset | null;
time_start?: number | null; time_start?: number | null;
@@ -395,6 +395,7 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
// Generate geometry metadata onto feature properties (optional in persisted snapshot). // Generate geometry metadata onto feature properties (optional in persisted snapshot).
const geo = geometryById.get(gid) || null; const geo = geometryById.get(gid) || null;
const existingSource = p.source === "inline" || p.source === "ref" ? p.source : undefined;
if (geo) { if (geo) {
const geoRecord = geo as unknown as UnknownRecord; const geoRecord = geo as unknown as UnknownRecord;
// type can arrive as numeric geo_type, numeric string, or semantic key depending on backend version. // type can arrive as numeric geo_type, numeric string, or semantic key depending on backend version.
@@ -406,6 +407,7 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
if (geo.bound_with !== undefined) { if (geo.bound_with !== undefined) {
p.bound_with = geo.bound_with; p.bound_with = geo.bound_with;
} }
p.source = geo.source || existingSource || "inline";
const timeStart = normalizeTimelineYearValue(geo.time_start); const timeStart = normalizeTimelineYearValue(geo.time_start);
const timeEnd = normalizeTimelineYearValue(geo.time_end); const timeEnd = normalizeTimelineYearValue(geo.time_end);
if (timeStart !== null) { if (timeStart !== null) {
@@ -418,10 +420,13 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
} else { } else {
delete p.time_end; delete p.time_end;
} }
} else if (!existingTypeKey) { } else {
p.source = existingSource || "inline";
if (!existingTypeKey) {
p.type = fallbackTypeKey; p.type = fallbackTypeKey;
} }
} }
}
return cloned; return cloned;
})(); })();
@@ -570,7 +575,7 @@ export function buildEditorSnapshot(options: {
return { return {
id, id,
operation, operation,
source: "inline", source: feature.properties.source || "inline",
type: typeKey, type: typeKey,
draw_geometry: feature.geometry, draw_geometry: feature.geometry,
bound_with: normalizeFeatureBoundWith(feature), bound_with: normalizeFeatureBoundWith(feature),
@@ -644,6 +649,7 @@ export function buildEditorSnapshot(options: {
delete p.time_end; delete p.time_end;
delete p.bound_with; delete p.bound_with;
delete p.binding; delete p.binding;
delete p.source;
delete p.entity_id; delete p.entity_id;
delete p.entity_ids; delete p.entity_ids;
delete p.entity_name; delete p.entity_name;
+1
View File
@@ -17,6 +17,7 @@ export type FeatureId = string | number;
export type FeatureProperties = { export type FeatureProperties = {
id: FeatureId; id: FeatureId;
source?: "inline" | "ref";
type?: string | null; type?: string | null;
geometry_preset?: GeometryPreset | null; geometry_preset?: GeometryPreset | null;
time_start?: number | null; time_start?: number | null;