feat: add custom template CRUD UI

This commit is contained in:
Aleix Soler 2025-08-06 18:11:12 +02:00
parent 392447c117
commit ddcb49ab84
9 changed files with 807 additions and 84 deletions

View file

@ -1,12 +1,10 @@
import { NoteStatus } from "@/types/noteStatus";
import { PluginSettings } from "@/types/pluginSettings";
import React from "react";
import { Input } from "@/components/atoms/Input";
export type Props = {
status: NoteStatus;
index: number;
settings: PluginSettings;
onCustomStatusChange: (
index: number,
column: "name" | "icon" | "color" | "description",

View file

@ -0,0 +1,189 @@
import React, { useState, useCallback } from "react";
import { StatusTemplate } from "@/types/pluginSettings";
import { NoteStatus } from "@/types/noteStatus";
import { Input } from "@/components/atoms/Input";
import { SettingItem } from "./SettingItem";
import { CustomStatusItem } from "./CustomStatusItem";
interface TemplateEditorModalProps {
template?: StatusTemplate;
onSave: (template: StatusTemplate) => void;
onCancel: () => void;
}
export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
template,
onSave,
onCancel,
}) => {
const [name, setName] = useState(template?.name || "");
const [description, setDescription] = useState(template?.description || "");
const [statuses, setStatuses] = useState<NoteStatus[]>(
template?.statuses || [
{
name: "",
icon: "",
color: "#888888",
templateId: template?.id || "",
},
],
);
const isEditing = !!template;
const isValid =
name.trim().length > 0 &&
description.trim().length > 0 &&
statuses.some((s) => s.name.trim().length > 0);
const handleSave = useCallback(() => {
if (!isValid) return;
const templateId = template?.id || `custom-${Date.now()}`;
const validStatuses = statuses.filter((s) => s.name.trim().length > 0);
// Update templateId for all statuses
const statusesWithTemplateId = validStatuses.map((s) => ({
...s,
templateId,
}));
const savedTemplate: StatusTemplate = {
id: templateId,
name: name.trim(),
description: description.trim(),
statuses: statusesWithTemplateId,
isCustom: true,
};
onSave(savedTemplate);
}, [name, description, statuses, template, isValid, onSave]);
const handleAddStatus = useCallback(() => {
setStatuses((prev) => [
...prev,
{
name: "",
icon: "",
color: "#888888",
templateId: template?.id || "",
},
]);
}, [template?.id]);
const handleStatusChange = useCallback(
(
index: number,
column: "name" | "icon" | "color" | "description",
value: string,
) => {
setStatuses((prev) =>
prev.map((status, i) =>
i === index ? { ...status, [column]: value } : status,
),
);
},
[],
);
const handleStatusRemove = useCallback((index: number) => {
setStatuses((prev) => prev.filter((_, i) => i !== index));
}, []);
const handleMoveUp = useCallback((index: number) => {
if (index <= 0) return;
setStatuses((prev) => {
const newStatuses = [...prev];
[newStatuses[index - 1], newStatuses[index]] = [
newStatuses[index],
newStatuses[index - 1],
];
return newStatuses;
});
}, []);
const handleMoveDown = useCallback((index: number) => {
setStatuses((prev) => {
if (index >= prev.length - 1) return prev;
const newStatuses = [...prev];
[newStatuses[index], newStatuses[index + 1]] = [
newStatuses[index + 1],
newStatuses[index],
];
return newStatuses;
});
}, []);
return (
<div className="template-editor-modal">
<div className="template-editor-modal__header">
<h2>{isEditing ? "Edit Template" : "Create New Template"}</h2>
</div>
<div className="template-editor-modal__content">
<SettingItem
name="Template Name"
description="A unique name for your template"
>
<Input
variant="text"
value={name}
onChange={setName}
placeholder="e.g. Project Workflow"
/>
</SettingItem>
<SettingItem
name="Description"
description="Brief description of the template's purpose"
>
<Input
variant="text"
value={description}
onChange={setDescription}
placeholder="e.g. Status workflow for project management"
/>
</SettingItem>
<SettingItem
name="Statuses"
description="Define the statuses included in this template"
vertical
>
<div className="template-editor-statuses">
{statuses.map((status, index) => (
<CustomStatusItem
key={index}
status={status}
index={index}
onCustomStatusChange={handleStatusChange}
onCustomStatusRemove={handleStatusRemove}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
canMoveUp={index > 0}
canMoveDown={index < statuses.length - 1}
/>
))}
<button
className="mod-cta template-editor-add-status"
onClick={handleAddStatus}
>
+ Add Status
</button>
</div>
</SettingItem>
</div>
<div className="template-editor-modal__actions">
<button
className="mod-cta"
onClick={handleSave}
disabled={!isValid}
>
{isEditing ? "Save Changes" : "Create Template"}
</button>
<button onClick={onCancel}>Cancel</button>
</div>
</div>
);
};

View file

@ -2,47 +2,120 @@ import React from "react";
import { StatusTemplate } from "@/types/pluginSettings";
import { StatusDisplay } from "../atoms/StatusDisplay";
import { SelectableListItem } from "../atoms/SelectableListItem";
import { isCustomTemplate } from "@/utils/templateUtils";
interface TemplateItemProps {
template: StatusTemplate;
isEnabled: boolean;
onToggle: (templateId: string, enabled: boolean) => void;
onEdit?: (template: StatusTemplate) => void;
onDelete?: (templateId: string) => void;
}
export const TemplateItem: React.FC<TemplateItemProps> = ({
template,
isEnabled,
onToggle,
}) => (
<SelectableListItem
selected={isEnabled}
onClick={() => onToggle(template.id, !isEnabled)}
className={`template-item ${isEnabled ? "enabled" : ""}`}
icon={
<input
type="checkbox"
className="template-checkbox"
checked={isEnabled}
readOnly
/>
onEdit,
onDelete,
}) => {
const handleClick = (e: React.MouseEvent) => {
// Don't toggle if clicking action buttons
if ((e.target as HTMLElement).closest(".template-item-actions")) {
return;
}
>
<div>
<div className="template-header">
<span className="setting-item-name">{template.name}</span>
</div>
<div className="setting-item-description">
{template.description}:
</div>
<div className="template-statuses">
{template.statuses.map((status, index) => (
<StatusDisplay
key={index}
status={status}
variant="template"
onToggle(template.id, !isEnabled);
};
return (
<div className={`template-item ${isEnabled ? "enabled" : ""}`}>
<SelectableListItem
selected={isEnabled}
onClick={handleClick}
className="template-item-content"
icon={
<input
type="checkbox"
className="template-checkbox"
checked={isEnabled}
readOnly
/>
))}
}
>
<div className="template-item-main">
<div className="template-header">
<span className="setting-item-name">
{template.name}
</span>
{isCustomTemplate(template.id) && (
<span className="template-custom-badge">
Custom
</span>
)}
</div>
<div className="setting-item-description">
{template.description}:
</div>
<div className="template-statuses">
{template.statuses.map((status, index) => (
<StatusDisplay
key={index}
status={status}
variant="template"
/>
))}
</div>
</div>
</SelectableListItem>
<div className="template-item-actions">
{onEdit && (
<button
className="template-action-btn template-action-btn--edit"
onClick={(e) => {
e.stopPropagation();
onEdit(template);
}}
title="Edit template"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="m18.5 2.5 4 4L13 16l-4-4L18.5 2.5z" />
</svg>
</button>
)}
{onDelete && (
<button
className="template-action-btn template-action-btn--delete"
onClick={(e) => {
e.stopPropagation();
onDelete(template.id);
}}
title="Delete template"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3,6 5,6 21,6" />
<path d="M19,6l-2,14a2,2,0,0,1-2,2H9a2,2,0,0,1-2-2L5,6" />
<path d="M10,11V17" />
<path d="M14,11V17" />
<path d="M9,6V4a2,2,0,0,1,2-2h2a2,2,0,0,1,2,2V6" />
</svg>
</button>
)}
</div>
</div>
</SelectableListItem>
);
);
};

View file

@ -1,51 +1,204 @@
import React, { useCallback } from "react";
import { PluginSettings } from "@/types/pluginSettings";
import React, { useCallback, useState } from "react";
import { PluginSettings, StatusTemplate } from "@/types/pluginSettings";
import { TemplateItem } from "./TemplateItem";
import { TemplateEditorModal } from "./TemplateEditorModal";
import {
generateTemplateId,
isTemplateNameUnique,
} from "@/utils/templateUtils";
interface TemplateSettingsProps {
settings: PluginSettings;
onChange: (key: keyof PluginSettings, value: unknown) => void;
templates: StatusTemplate[];
}
export const TemplateSettings: React.FC<TemplateSettingsProps> = ({
settings,
onChange,
}) => {
const [showEditor, setShowEditor] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<
StatusTemplate | undefined
>();
const handleTemplateToggle = useCallback(
(templateId: string, enabled: boolean) => {
let newEnabledTemplated = [...settings.enabledTemplates];
let newEnabledTemplates = [...settings.enabledTemplates];
if (enabled) {
if (!newEnabledTemplated.includes(templateId)) {
newEnabledTemplated.push(templateId);
if (!newEnabledTemplates.includes(templateId)) {
newEnabledTemplates.push(templateId);
}
} else {
newEnabledTemplated = newEnabledTemplated.filter(
newEnabledTemplates = newEnabledTemplates.filter(
(id: string) => id !== templateId,
);
}
onChange("enabledTemplates", newEnabledTemplated);
onChange("enabledTemplates", newEnabledTemplates);
},
[onChange, settings.enabledTemplates],
);
const handleCreateTemplate = useCallback(() => {
setEditingTemplate(undefined);
setShowEditor(true);
}, []);
const handleEditTemplate = useCallback((template: StatusTemplate) => {
setEditingTemplate(template);
setShowEditor(true);
}, []);
const handleSaveTemplate = useCallback(
(template: StatusTemplate) => {
// Validate name uniqueness
if (!isTemplateNameUnique(template.name, editingTemplate?.id)) {
alert(
"A template with this name already exists. Please choose a different name.",
);
return;
}
let finalTemplate = template;
if (editingTemplate) {
// Update existing template - keep same ID
finalTemplate = { ...template, id: editingTemplate.id };
const updatedTemplates = settings.templates.map((t) =>
t.id === editingTemplate.id ? finalTemplate : t,
);
onChange("templates", updatedTemplates);
} else {
// Add new template - generate unique ID
const uniqueId = generateTemplateId(template.name);
finalTemplate = { ...template, id: uniqueId };
// Update statuses with the final template ID
finalTemplate.statuses = finalTemplate.statuses.map(
(status) => ({
...status,
templateId: uniqueId,
}),
);
onChange("templates", [...settings.templates, finalTemplate]);
// Auto-enable new templates
handleTemplateToggle(uniqueId, true);
}
setShowEditor(false);
setEditingTemplate(undefined);
},
[editingTemplate, settings, onChange, handleTemplateToggle],
);
const handleDeleteTemplate = useCallback(
(templateId: string) => {
// Show confirmation
const confirmed = confirm(
"Are you sure you want to delete this template? This action cannot be undone.",
);
if (!confirmed) return;
// Remove from custom templates
const updatedTemplates = (settings.templates || []).filter(
(t) => t.id !== templateId,
);
onChange("templates", updatedTemplates);
// Remove from enabled templates
const updatedEnabled = settings.enabledTemplates.filter(
(id) => id !== templateId,
);
onChange("enabledTemplates", updatedEnabled);
},
[settings.templates, settings.enabledTemplates, onChange],
);
const handleResetToDefaults = useCallback(() => {
const confirmed = confirm(
"Reset to default templates? This will:\n" +
"• Remove all custom templates\n" +
"• Reset enabled templates to defaults\n" +
"This action cannot be undone.",
);
if (!confirmed) return;
onChange("templates", []);
onChange("enabledTemplates", ["colorful"]);
}, [onChange]);
const handleCancelEditor = useCallback(() => {
setShowEditor(false);
setEditingTemplate(undefined);
}, []);
if (showEditor) {
return (
<div>
<h3>Status templates</h3>
<TemplateEditorModal
template={editingTemplate}
onSave={handleSaveTemplate}
onCancel={handleCancelEditor}
/>
</div>
);
}
return (
<div>
<h3>Status templates</h3>
<p>
Enable predefined templates to quickly add common status
workflows
workflows, or create your own custom templates.
</p>
<div>
{settings.templates.map((template) => (
<TemplateItem
key={template.id}
template={template}
isEnabled={settings.enabledTemplates.includes(
template.id,
)}
onToggle={handleTemplateToggle}
/>
))}
<div className="template-settings-actions">
<button
className="mod-cta template-create-btn"
onClick={handleCreateTemplate}
>
+ Create Template
</button>
<button
className="template-reset-btn"
onClick={handleResetToDefaults}
>
🔄 Reset to Defaults
</button>
</div>
{/* Custom Templates Section */}
<div className="template-section">
<h4 className="template-section-title">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M20 14.66V20a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5.34" />
<polygon points="18,2 22,6 12,16 8,16 8,12 18,2" />
</svg>
Custom Templates
</h4>
<div className="template-list">
{settings.templates.map((template) => (
<TemplateItem
key={template.id}
template={template}
isEnabled={settings.enabledTemplates.includes(
template.id,
)}
onToggle={handleTemplateToggle}
onEdit={handleEditTemplate}
onDelete={handleDeleteTemplate}
/>
))}
</div>
</div>
</div>
);

View file

@ -5,7 +5,7 @@ interface SelectableListItemProps {
focused?: boolean;
icon?: ReactNode;
children: ReactNode;
onClick?: () => void;
onClick?: (e: React.MouseEvent) => void;
className?: string;
title?: string;
}
@ -21,10 +21,10 @@ export const SelectableListItem: React.FC<SelectableListItemProps> = ({
}) => {
const [isHovered, setIsHovered] = useState(false);
const handleClick = () => {
const handleClick = (e: React.MouseEvent) => {
if (onClick) {
setTimeout(() => {
onClick();
onClick(e);
}, 150);
}
};

View file

@ -15,37 +15,6 @@
gap: var(--size-2-2);
}
/* Template Item */
.template-item {
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
margin-bottom: var(--size-4-2);
padding: var(--size-4-3);
background: var(--background-primary-alt);
cursor: pointer;
transition: all var(--anim-duration-fast) ease;
}
.template-item:hover {
background: var(--background-modifier-hover);
}
.template-item.enabled {
background: var(--background-modifier-success);
border-color: var(--interactive-success);
}
.template-checkbox {
pointer-events: none;
}
.template-statuses {
margin-top: var(--size-2-2);
display: flex;
flex-wrap: wrap;
gap: var(--size-2-1);
}
/* Custom Status Item - BEM Methodology */
.custom-status-item {
background: var(--background-secondary);

View file

@ -0,0 +1,288 @@
/* ==========================================================================
Template Settings Components
========================================================================== */
/* Template Settings Actions */
.template-settings-actions {
display: flex;
gap: var(--size-4-2);
margin-bottom: var(--size-4-4);
padding: var(--size-4-2);
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
}
.template-create-btn {
display: flex;
align-items: center;
gap: var(--size-2-2);
padding: var(--size-2-3) var(--size-4-3);
font-weight: var(--font-medium);
font-size: var(--font-ui-medium);
}
.template-reset-btn {
display: flex;
align-items: center;
gap: var(--size-2-2);
padding: var(--size-2-3) var(--size-4-3);
background: var(--background-modifier-border);
border: 1px solid var(--background-modifier-border-hover);
border-radius: var(--radius-s);
color: var(--text-normal);
cursor: pointer;
font-size: var(--font-ui-medium);
transition: all var(--anim-duration-fast) ease;
}
.template-reset-btn:hover {
background: var(--background-modifier-border-hover);
color: var(--text-accent);
}
/* Template Sections */
.template-section {
margin-bottom: var(--size-4-6);
}
.template-section-title {
display: flex;
align-items: center;
gap: var(--size-2-2);
font-size: var(--font-ui-large);
font-weight: var(--font-semibold);
color: var(--text-normal);
margin-bottom: var(--size-4-3);
padding-bottom: var(--size-2-2);
border-bottom: 2px solid var(--background-modifier-border);
}
.template-section-title svg {
opacity: 0.8;
}
.template-list {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
}
/* Template Item Updates */
.template-item {
position: relative;
display: flex;
align-items: stretch;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
background: var(--background-primary-alt);
transition: all var(--anim-duration-fast) ease;
overflow: hidden;
}
.template-item:hover {
background: var(--background-modifier-hover);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.template-item.enabled {
background: var(--background-modifier-success);
border-color: var(--interactive-success);
}
.template-item-content {
flex: 1;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
}
.template-item-main {
flex: 1;
}
.template-header {
display: flex;
align-items: center;
gap: var(--size-2-2);
margin-bottom: var(--size-2-1);
}
.template-custom-badge {
padding: var(--size-2-1) var(--size-2-2);
background: var(--interactive-accent);
color: var(--text-on-accent);
font-size: var(--font-ui-smaller);
font-weight: var(--font-medium);
border-radius: var(--radius-s);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Template Item Actions */
.template-item-actions {
display: flex;
flex-direction: column;
padding: var(--size-4-2);
gap: var(--size-2-1);
background: var(--background-modifier-border);
border-left: 1px solid var(--background-modifier-border-hover);
min-width: 44px;
align-items: center;
justify-content: center;
}
.template-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-s);
cursor: pointer;
transition: all var(--anim-duration-fast) ease;
color: var(--text-muted);
}
.template-action-btn:hover {
transform: scale(1.1);
}
.template-action-btn--edit {
background: var(--background-modifier-border-hover);
color: var(--text-accent);
}
.template-action-btn--edit:hover {
background: var(--interactive-accent);
color: var(--text-on-accent);
}
.template-action-btn--delete {
background: var(--background-modifier-error);
color: var(--text-error);
}
.template-action-btn--delete:hover {
background: var(--text-error);
color: var(--text-on-accent);
}
/* Template Editor Modal */
.template-editor-modal {
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-l);
box-shadow: var(--shadow-l);
max-width: 800px;
width: 100%;
}
.template-editor-modal__header {
padding: var(--size-4-4) var(--size-4-6);
background: var(--background-secondary);
border-bottom: 1px solid var(--background-modifier-border);
border-radius: var(--radius-l) var(--radius-l) 0 0;
}
.template-editor-modal__header h2 {
margin: 0;
font-size: var(--font-ui-larger);
font-weight: var(--font-bold);
color: var(--text-normal);
}
.template-editor-modal__content {
padding: var(--size-4-6);
max-height: 70vh;
overflow-y: auto;
}
.template-editor-statuses {
display: flex;
flex-direction: column;
gap: var(--size-4-3);
}
.template-editor-add-status {
align-self: flex-start;
margin-top: var(--size-4-2);
}
.template-editor-modal__actions {
display: flex;
gap: var(--size-4-2);
justify-content: flex-end;
padding: var(--size-4-4) var(--size-4-6);
background: var(--background-secondary);
border-top: 1px solid var(--background-modifier-border);
border-radius: 0 0 var(--radius-l) var(--radius-l);
}
.template-editor-modal__actions button {
padding: var(--size-2-3) var(--size-4-4);
border-radius: var(--radius-m);
font-size: var(--font-ui-medium);
font-weight: var(--font-medium);
cursor: pointer;
transition: all var(--anim-duration-fast) ease;
}
.template-editor-modal__actions button:not(.mod-cta) {
background: var(--background-modifier-border);
border: 1px solid var(--background-modifier-border-hover);
color: var(--text-normal);
}
.template-editor-modal__actions button:not(.mod-cta):hover {
background: var(--background-modifier-border-hover);
}
.template-editor-modal__actions .mod-cta:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.template-editor-modal__actions .mod-cta:disabled:hover {
transform: none;
}
/* Responsive Design */
@media (max-width: 768px) {
.template-settings-actions {
flex-direction: column;
}
.template-item {
flex-direction: column;
}
.template-item-actions {
flex-direction: row;
border-left: none;
border-top: 1px solid var(--background-modifier-border-hover);
min-width: unset;
padding: var(--size-2-3) var(--size-4-2);
}
.template-editor-modal {
margin: var(--size-4-2);
max-width: calc(100% - var(--size-4-4));
}
}
/* Scrollbar for template editor */
.template-editor-modal__content::-webkit-scrollbar {
width: var(--scrollbar-width);
}
.template-editor-modal__content::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb-bg);
border-radius: var(--radius-s);
}
.template-editor-modal__content::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-active-bg);
}

View file

@ -8,6 +8,7 @@
/* Import component stylesheets */
@import "components/status-bar.css";
@import "components/settings.css";
@import "components/template-settings.css";
@import "components/dashboard.css";
@import "components/grouped-view.css";
@import "components/file-explorer.css";

52
utils/templateUtils.ts Normal file
View file

@ -0,0 +1,52 @@
import { StatusTemplate } from "@/types/pluginSettings";
import settingsService from "@/core/settingsService";
import { PREDEFINED_TEMPLATES } from "@/constants/predefinedTemplates";
/**
* Get all enabled templates
*/
export const isCustomTemplate = (templateId: string): boolean => {
const i = PREDEFINED_TEMPLATES.findIndex((t) => t.id === templateId);
return i === -1;
};
/**
* Get template by ID
*/
export const getTemplateById = (id: string): StatusTemplate | undefined => {
const allTemplates = settingsService.settings.templates;
return allTemplates.find((template) => template.id === id);
};
/**
* Validate template name uniqueness
*/
export const isTemplateNameUnique = (
name: string,
excludeId?: string,
): boolean => {
const allTemplates = settingsService.settings.templates;
return !allTemplates.some(
(template) =>
template.name.toLowerCase() === name.toLowerCase() &&
template.id !== excludeId,
);
};
/**
* Generate unique template ID
*/
export const generateTemplateId = (name: string): string => {
const baseId = name.toLowerCase().replace(/[^a-z0-9]/g, "-");
const allTemplates = settingsService.settings.templates;
let counter = 1;
let id = `custom-${baseId}`;
while (allTemplates.some((template) => template.id === id)) {
id = `custom-${baseId}-${counter}`;
counter++;
}
return id;
};