mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
feat: align list editors with name hover actions
This commit is contained in:
1 parent
bdf0e68644
commit
de7a668b32
7 files changed
+188
-77
No files matched your search
+14
-1
@@ -405,9 +405,22 @@ func (d *DB) ListScheduleHistory(id int64) ([]ScheduleHistory, error) {
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// TaskSchedulePatch changes list metadata without resetting execution windows.
|
||||
type TaskSchedulePatch struct {
|
||||
Name *string
|
||||
Remark *string
|
||||
}
|
||||
|
||||
// UpdateTaskScheduleRemark leaves calendar state and task bindings untouched.
|
||||
func (d *DB) UpdateTaskScheduleRemark(id int64, remark string) (*TaskSchedule, error) {
|
||||
item, err := scanSchedule(d.QueryRow(`UPDATE task_schedules SET remark=$2 WHERE id=$1 RETURNING `+scheduleCols, id, remark))
|
||||
return d.UpdateTaskScheduleMetadata(id, TaskSchedulePatch{Remark: &remark})
|
||||
}
|
||||
|
||||
func (d *DB) UpdateTaskScheduleMetadata(id int64, patch TaskSchedulePatch) (*TaskSchedule, error) {
|
||||
item, err := scanSchedule(d.QueryRow(`UPDATE task_schedules SET
|
||||
name=CASE WHEN $2::boolean THEN $3 ELSE name END,
|
||||
remark=CASE WHEN $4::boolean THEN $5 ELSE remark END
|
||||
WHERE id=$1 RETURNING `+scheduleCols, id, patch.Name != nil, patch.Name, patch.Remark != nil, patch.Remark))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
+22
-8
@@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
const maxRemarkRunes = 2000
|
||||
@@ -18,7 +20,7 @@ func normalizeRemark(value string) (string, error) {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Server) updateScheduleRemark(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) updateScheduleMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseScheduleID(r)
|
||||
if err != nil || id <= 0 {
|
||||
writeErr(w, 400, "计划编号无效")
|
||||
@@ -26,6 +28,7 @@ func (s *Server) updateScheduleRemark(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxTaskMetadataRequestSize)
|
||||
var request struct {
|
||||
Name *string `json:"name"`
|
||||
Remark *string `json:"remark"`
|
||||
}
|
||||
if err := decode(r, &request); err != nil {
|
||||
@@ -37,16 +40,27 @@ func (s *Server) updateScheduleRemark(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return
|
||||
}
|
||||
if request.Remark == nil {
|
||||
writeErr(w, 400, "需要提供 remark")
|
||||
if request.Name == nil && request.Remark == nil {
|
||||
writeErr(w, 400, "需要提供 name 或 remark")
|
||||
return
|
||||
}
|
||||
remark, err := normalizeRemark(*request.Remark)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
if request.Name != nil {
|
||||
name := strings.TrimSpace(*request.Name)
|
||||
if name == "" || utf8.RuneCountInString(name) > maxTaskNameRunes {
|
||||
writeErr(w, 400, fmt.Sprintf("计划名称须为 1 至 %d 个字符", maxTaskNameRunes))
|
||||
return
|
||||
}
|
||||
request.Name = &name
|
||||
}
|
||||
item, err := s.m.pg.UpdateTaskScheduleRemark(id, remark)
|
||||
if request.Remark != nil {
|
||||
remark, err := normalizeRemark(*request.Remark)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
request.Remark = &remark
|
||||
}
|
||||
item, err := s.m.pg.UpdateTaskScheduleMetadata(id, db.TaskSchedulePatch{Name: request.Name, Remark: request.Remark})
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
|
||||
@@ -157,6 +157,30 @@ func TestRemarksRoundTripPreservesExecutionState(t *testing.T) {
|
||||
if _, err := m.pg.UpdateTaskScheduleRemark(schedule.ID, note); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadataPath := fmt.Sprintf("/api/schedules/%d/metadata", schedule.ID)
|
||||
rec = request(metadataPath, `{"name":" 行内改名 "}`, true)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("schedule rename: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
renamed, err := m.pg.GetTaskSchedule(schedule.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if renamed.Name != "行内改名" || renamed.Remark != note || renamed.Status != original.Status || renamed.Enabled != original.Enabled || renamed.LastWindowKey != original.LastWindowKey || renamed.LastError != original.LastError || !renamed.LastTransitionAt.Equal(*original.LastTransitionAt) || len(renamed.TaskIDs) != 1 || renamed.TaskIDs[0] != taskID {
|
||||
t.Fatalf("rename changed calendar state or metadata: %+v", renamed)
|
||||
}
|
||||
for _, body := range []string{`{}`, `{"name":null}`, `{"name":" "}`, `{"name":123}`, `{"name":"` + strings.Repeat("文", maxTaskNameRunes+1) + `"}`, `{"name":"must not apply","remark":"` + strings.Repeat("文", maxRemarkRunes+1) + `"}`} {
|
||||
if rec := request(metadataPath, body, true); rec.Code != 400 {
|
||||
t.Fatalf("invalid rename: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
unchanged, err := m.pg.GetTaskSchedule(schedule.ID)
|
||||
if err != nil || unchanged.Name != "行内改名" || unchanged.Remark != note {
|
||||
t.Fatalf("invalid patch partially changed metadata: %+v %v", unchanged, err)
|
||||
}
|
||||
if rec := request(metadataPath, `{"name":"denied"}`, false); rec.Code != 401 {
|
||||
t.Fatalf("rename auth: %d", rec.Code)
|
||||
}
|
||||
scheduleInput.Name = "新计划名称"
|
||||
updated, err := m.pg.UpdateTaskSchedule(schedule.ID, scheduleInput)
|
||||
if err != nil || updated.Remark != note {
|
||||
|
||||
+2
-1
@@ -717,7 +717,8 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/schedules", s.createSchedule)
|
||||
mux.HandleFunc("GET /api/schedules/{id}", s.getSchedule)
|
||||
mux.HandleFunc("PATCH /api/schedules/{id}", s.updateSchedule)
|
||||
mux.HandleFunc("PATCH /api/schedules/{id}/remark", s.updateScheduleRemark)
|
||||
mux.HandleFunc("PATCH /api/schedules/{id}/remark", s.updateScheduleMetadata)
|
||||
mux.HandleFunc("PATCH /api/schedules/{id}/metadata", s.updateScheduleMetadata)
|
||||
mux.HandleFunc("DELETE /api/schedules/{id}", s.deleteSchedule)
|
||||
mux.HandleFunc("POST /api/schedules/{id}/pause", s.pauseSchedule)
|
||||
mux.HandleFunc("POST /api/schedules/{id}/resume", s.resumeSchedule)
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { DateSelect } from "@/components/date-select";
|
||||
import { RemarkEditor } from "@/components/remark-editor";
|
||||
import { RemarkEditor, ScheduleNameEditor } from "@/components/remark-editor";
|
||||
import { TimeSelect } from "@/components/time-select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -703,7 +703,7 @@ export default function SchedulesPage() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border bg-card">
|
||||
<Table>
|
||||
<Table className="**:data-[slot='table-cell']:px-4 **:data-[slot='table-head']:px-4">
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
@@ -729,7 +729,7 @@ export default function SchedulesPage() {
|
||||
<TableBody>
|
||||
{visibleItems.map((item) => (
|
||||
<React.Fragment key={item.id}>
|
||||
<TableRow>
|
||||
<TableRow className="group">
|
||||
<TableCell className="w-12">
|
||||
<Checkbox
|
||||
aria-label={`选择计划 ${item.name}`}
|
||||
@@ -740,7 +740,16 @@ export default function SchedulesPage() {
|
||||
<TableCell>
|
||||
<code className="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">{item.id}</code>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{item.name}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<ScheduleNameEditor
|
||||
value={item.name}
|
||||
label={`计划 #${item.id}`}
|
||||
onSave={async (name) => {
|
||||
const updated = await api.renameSchedule(item.id, name);
|
||||
setItems((current) => current.map((entry) => entry.id === item.id ? { ...entry, name: updated.name } : entry));
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RemarkEditor
|
||||
value={item.remark}
|
||||
|
||||
@@ -2,27 +2,43 @@
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { PencilIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MAX_REMARK_CHARS = 2000;
|
||||
type EditorProps = {
|
||||
value?: string;
|
||||
label: string;
|
||||
onSave: (value: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function RemarkEditor({
|
||||
export function RemarkEditor(props: EditorProps) {
|
||||
return <InlineTextEditor {...props} multiline fieldName="备注" maxCharacters={2000} emptyLabel="添加备注" />;
|
||||
}
|
||||
|
||||
export function ScheduleNameEditor(props: EditorProps) {
|
||||
return <InlineTextEditor {...props} multiline={false} fieldName="名称" maxCharacters={200} emptyLabel="未命名" />;
|
||||
}
|
||||
|
||||
function InlineTextEditor({
|
||||
value = "",
|
||||
label,
|
||||
onSave,
|
||||
}: {
|
||||
value?: string;
|
||||
label: string;
|
||||
onSave: (remark: string) => Promise<void>;
|
||||
}) {
|
||||
multiline,
|
||||
fieldName,
|
||||
maxCharacters,
|
||||
emptyLabel,
|
||||
}: EditorProps & { multiline: boolean; fieldName: string; maxCharacters: number; emptyLabel: string }) {
|
||||
const [editing, setEditing] = React.useState(false);
|
||||
const [draft, setDraft] = React.useState(value);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState("");
|
||||
const inputRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const cancelOnBlurRef = React.useRef(false);
|
||||
const savingRef = React.useRef(false);
|
||||
const errorID = React.useId();
|
||||
@@ -31,6 +47,10 @@ export function RemarkEditor({
|
||||
if (!editing) setDraft(value);
|
||||
}, [editing, value]);
|
||||
|
||||
function refocus() {
|
||||
requestAnimationFrame(() => (multiline ? textareaRef.current : inputRef.current)?.focus());
|
||||
}
|
||||
|
||||
async function finishEditing() {
|
||||
if (savingRef.current) return;
|
||||
if (cancelOnBlurRef.current) {
|
||||
@@ -45,20 +65,25 @@ export function RemarkEditor({
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
if (Array.from(next).length > MAX_REMARK_CHARS) {
|
||||
setError(`备注最多 ${MAX_REMARK_CHARS} 个字符`);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
if (!multiline && next.length === 0) {
|
||||
setError("名称不能为空");
|
||||
refocus();
|
||||
return;
|
||||
}
|
||||
if (Array.from(next).length > maxCharacters) {
|
||||
setError(`${fieldName}最多 ${maxCharacters} 个字符`);
|
||||
refocus();
|
||||
return;
|
||||
}
|
||||
savingRef.current = true;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(next);
|
||||
toast.success("备注已保存");
|
||||
toast.success(`${fieldName}已保存`);
|
||||
setEditing(false);
|
||||
} catch (cause) {
|
||||
setError(`备注保存失败:${(cause as Error).message}`);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
setError(`${fieldName}保存失败:${(cause as Error).message}`);
|
||||
refocus();
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
setSaving(false);
|
||||
@@ -66,38 +91,45 @@ export function RemarkEditor({
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
const inputProps = {
|
||||
value: draft,
|
||||
autoFocus: true,
|
||||
disabled: saving,
|
||||
"aria-busy": saving,
|
||||
"aria-label": `${label}${fieldName}`,
|
||||
"aria-invalid": error.length > 0,
|
||||
"aria-describedby": error ? errorID : undefined,
|
||||
title: multiline ? "Enter 保存,Shift+Enter 换行,Esc 取消;留空可清除备注" : "Enter 或失焦保存,Esc 取消",
|
||||
onFocus: (event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => event.currentTarget.select(),
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setDraft(event.target.value);
|
||||
setError("");
|
||||
},
|
||||
onBlur: () => void finishEditing(),
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
if (event.key === "Enter" && (!multiline || !event.shiftKey)) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.blur();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancelOnBlurRef.current = true;
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
},
|
||||
};
|
||||
return (
|
||||
<div className="flex w-32 min-w-28 flex-col gap-1 sm:w-48">
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
rows={1}
|
||||
autoFocus
|
||||
disabled={saving}
|
||||
aria-busy={saving}
|
||||
aria-label={`${label}备注`}
|
||||
aria-invalid={error.length > 0}
|
||||
aria-describedby={error ? errorID : undefined}
|
||||
className="max-h-40 min-h-7 resize-none px-2 py-1 [overflow-wrap:anywhere]"
|
||||
title="Enter 保存,Shift+Enter 换行,Esc 取消;留空可清除备注"
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
setError("");
|
||||
}}
|
||||
onBlur={() => void finishEditing()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.blur();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancelOnBlurRef.current = true;
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className={cn("flex min-w-0 flex-col gap-1 font-medium", multiline && "w-32 min-w-28 sm:w-48")}>
|
||||
{multiline ? (
|
||||
<Textarea
|
||||
{...inputProps}
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
className="max-h-40 min-h-7 resize-none px-2 py-1 [overflow-wrap:anywhere]"
|
||||
/>
|
||||
) : (
|
||||
<Input {...inputProps} ref={inputRef} className="h-7 min-w-28 max-w-48 px-2 font-medium" />
|
||||
)}
|
||||
{error ? (
|
||||
<p id={errorID} role="alert" className="whitespace-normal text-destructive text-xs">
|
||||
{error}
|
||||
@@ -107,25 +139,42 @@ export function RemarkEditor({
|
||||
);
|
||||
}
|
||||
|
||||
const startEditing = () => {
|
||||
setDraft(value);
|
||||
setError("");
|
||||
setEditing(true);
|
||||
};
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-7 w-32 justify-start p-0 sm:w-48"
|
||||
aria-label={`编辑${label}的备注`}
|
||||
title={value.length > 0 ? value : "点击编辑备注"}
|
||||
onClick={() => {
|
||||
setDraft(value);
|
||||
setError("");
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
{value.length > 0 ? (
|
||||
<span className="truncate">{value}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">添加备注</span>
|
||||
<div
|
||||
className={cn(
|
||||
"group/inline-editor flex min-w-0 items-center gap-1 font-medium",
|
||||
multiline ? "w-32 sm:w-48" : "max-w-xs",
|
||||
)}
|
||||
</Button>
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 truncate rounded-sm text-left hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={`${label}${fieldName},点击编辑`}
|
||||
title={value.length > 0 ? value : `点击编辑${fieldName}`}
|
||||
onClick={startEditing}
|
||||
>
|
||||
{value.length > 0 ? (
|
||||
<span className="block truncate">{value}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{emptyLabel}</span>
|
||||
)}
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="shrink-0 opacity-100 transition-opacity [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within:opacity-100 [@media(hover:hover)]:group-hover:opacity-100 [@media(hover:hover)]:group-focus-within/inline-editor:opacity-100 [@media(hover:hover)]:group-hover/inline-editor:opacity-100"
|
||||
aria-label={`编辑${label}的${fieldName}`}
|
||||
title={`编辑${fieldName}`}
|
||||
onClick={startEditing}
|
||||
>
|
||||
<PencilIcon />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -326,6 +326,7 @@ export const api = {
|
||||
schedule: input.schedule ? schedulePayload(input.schedule) : undefined,
|
||||
}),
|
||||
schedules: () => get<{ schedules: TaskSchedule[] }>("/schedules").then((r) => arr(r.schedules)),
|
||||
renameSchedule: (id: number, name: string) => patch<TaskSchedule>(`/schedules/${id}/metadata`, { name }),
|
||||
updateScheduleRemark: (id: number, remark: string) => patch<TaskSchedule>(`/schedules/${id}/remark`, { remark }),
|
||||
schedule: (id: number) => get<TaskSchedule>(`/schedules/${id}`),
|
||||
createSchedule: (input: ScheduleInput) => post<TaskSchedule>("/schedules", schedulePayload(input)),
|
||||
|
||||
Reference in new issue
Block a user