Skip to content

Commit 0d36d37

Browse files
committed
plugin: add support for default labels in settings
1 parent c687d83 commit 0d36d37

File tree

7 files changed

+283
-1
lines changed

7 files changed

+283
-1
lines changed

plugin/src/i18n/langs/en.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ export const en: Translations = {
8080
deletedWarning: "This project no longer exists",
8181
deleted: "deleted",
8282
},
83+
defaultLabels: {
84+
label: "Default labels",
85+
description: "The default labels to apply when creating new tasks",
86+
buttonAddLabel: "Add label",
87+
buttonNoAvailableLabels: "No labels available",
88+
noLabels: "No labels configured",
89+
deletedWarning: "This label no longer exists",
90+
deleted: "deleted",
91+
},
8392
},
8493
advanced: {
8594
header: "Advanced",
@@ -110,6 +119,8 @@ export const en: Translations = {
110119
failedToFindInboxNotice: "Error: could not find inbox project",
111120
defaultProjectDeletedNotice: (projectName: string) =>
112121
`Default project "${projectName}" no longer exists. Using Inbox instead.`,
122+
defaultLabelsDeletedNotice: (labelNames: string) =>
123+
`Default labels no longer exist: ${labelNames}. Skipping deleted labels.`,
113124
dateSelector: {
114125
buttonLabel: "Set due date",
115126
dialogLabel: "Due date selector",

plugin/src/i18n/translation.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ export type Translations = {
7676
deletedWarning: string;
7777
deleted: string;
7878
};
79+
defaultLabels: {
80+
label: string;
81+
description: string;
82+
buttonAddLabel: string;
83+
buttonNoAvailableLabels: string;
84+
noLabels: string;
85+
deletedWarning: string;
86+
deleted: string;
87+
};
7988
};
8089
advanced: {
8190
header: string;
@@ -104,6 +113,7 @@ export type Translations = {
104113
addTaskButtonLabel: string;
105114
failedToFindInboxNotice: string;
106115
defaultProjectDeletedNotice: (projectName: string) => string;
116+
defaultLabelsDeletedNotice: (labelNames: string) => string;
107117
dateSelector: {
108118
buttonLabel: string;
109119
dialogLabel: string;

plugin/src/settings.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ export type ProjectDefaultSetting = {
99
projectName: string;
1010
} | null;
1111

12+
export type LabelsDefaultSetting = Array<{
13+
labelId: string;
14+
labelName: string;
15+
}>;
16+
1217
const defaultSettings: Settings = {
1318
fadeToggle: true,
1419

@@ -26,6 +31,8 @@ const defaultSettings: Settings = {
2631

2732
taskCreationDefaultProject: null,
2833

34+
taskCreationDefaultLabels: [],
35+
2936
debugLogging: false,
3037
};
3138

@@ -47,6 +54,8 @@ export type Settings = {
4754

4855
taskCreationDefaultProject: ProjectDefaultSetting;
4956

57+
taskCreationDefaultLabels: LabelsDefaultSetting;
58+
5059
debugLogging: boolean;
5160
};
5261

plugin/src/ui/createTaskModal/index.tsx

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { t } from "@/i18n";
88
import { timezone, today } from "@/infra/time";
99
import {
1010
type DueDateDefaultSetting,
11+
type LabelsDefaultSetting,
1112
type ProjectDefaultSetting,
1213
useSettingsStore,
1314
} from "@/settings";
@@ -89,6 +90,35 @@ const calculateDefaultProject = (
8990
};
9091
};
9192

93+
const calculateDefaultLabels = (
94+
plugin: TodoistPlugin,
95+
labelsSetting: LabelsDefaultSetting,
96+
): Label[] => {
97+
if (labelsSetting.length === 0) {
98+
return [];
99+
}
100+
101+
const allLabels = Array.from(plugin.services.todoist.data().labels.iter());
102+
const validLabels: Label[] = [];
103+
const deletedLabelNames: string[] = [];
104+
105+
for (const defaultLabel of labelsSetting) {
106+
const label = allLabels.find((l) => l.id === defaultLabel.labelId);
107+
if (label !== undefined) {
108+
validLabels.push(label);
109+
} else {
110+
deletedLabelNames.push(defaultLabel.labelName);
111+
}
112+
}
113+
114+
if (deletedLabelNames.length > 0) {
115+
const noticeMsg = t().createTaskModal.defaultLabelsDeletedNotice(deletedLabelNames.join(", "));
116+
new Notice(noticeMsg);
117+
}
118+
119+
return validLabels;
120+
};
121+
92122
export const CreateTaskModal: React.FC<CreateTaskProps> = (props) => {
93123
const plugin = PluginContext.use();
94124

@@ -132,7 +162,9 @@ const CreateTaskModalContent: React.FC<CreateTaskProps> = ({
132162
calculateDefaultDueDate(settings.taskCreationDefaultDueDate),
133163
);
134164
const [priority, setPriority] = useState<Priority>(1);
135-
const [labels, setLabels] = useState<Label[]>([]);
165+
const [labels, setLabels] = useState<Label[]>(() =>
166+
calculateDefaultLabels(plugin, settings.taskCreationDefaultLabels),
167+
);
136168
const [project, setProject] = useState<ProjectIdentifier>(
137169
calculateDefaultProject(plugin, settings.taskCreationDefaultProject),
138170
);
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import classNames from "classnames";
2+
import type React from "react";
3+
import { useMemo, useState } from "react";
4+
5+
import type { Label } from "@/api/domain/label";
6+
import { t } from "@/i18n";
7+
import type { LabelsDefaultSetting } from "@/settings";
8+
import { ObsidianIcon } from "@/ui/components/obsidian-icon";
9+
import { PluginContext } from "@/ui/context";
10+
11+
type Props = {
12+
value: LabelsDefaultSetting;
13+
onChange: (val: LabelsDefaultSetting) => Promise<void>;
14+
};
15+
16+
export const LabelsControl: React.FC<Props> = ({ value, onChange }) => {
17+
const [selected, setSelected] = useState(value);
18+
const plugin = PluginContext.use();
19+
const todoist = plugin.services.todoist;
20+
const i18n = t().settings.taskCreation.defaultLabels;
21+
22+
const labelsById: Map<string, Label> = useMemo(() => {
23+
if (!todoist.isReady()) {
24+
return new Map();
25+
}
26+
return new Map(Array.from(todoist.data().labels.iter()).map((label) => [label.id, label]));
27+
}, [todoist]);
28+
29+
const availableLabels = useMemo(() => {
30+
const selectedIds = selected.map((l) => l.labelId);
31+
return Array.from(labelsById.values()).filter((label) => !selectedIds.includes(label.id));
32+
}, [labelsById, selected]);
33+
34+
const removeLabel = async (labelId: string) => {
35+
const newValue = selected.filter((l) => l.labelId !== labelId);
36+
setSelected(newValue);
37+
await onChange(newValue);
38+
};
39+
40+
const handleAddLabelChange = async (ev: React.ChangeEvent<HTMLSelectElement>) => {
41+
ev.stopPropagation();
42+
ev.preventDefault();
43+
44+
const labelId = ev.target.value;
45+
if (labelId === "") {
46+
return;
47+
}
48+
49+
const label = labelsById.get(labelId);
50+
if (label === undefined) {
51+
return;
52+
}
53+
54+
const newValue = [...selected, { labelId: label.id, labelName: label.name }];
55+
setSelected(newValue);
56+
await onChange(newValue);
57+
};
58+
59+
return (
60+
<div className="labels-control-container">
61+
<select
62+
className="dropdown"
63+
value=""
64+
onChange={handleAddLabelChange}
65+
disabled={availableLabels.length === 0}
66+
>
67+
<option value="" disabled>
68+
{availableLabels.length === 0 ? i18n.buttonNoAvailableLabels : i18n.buttonAddLabel}
69+
</option>
70+
{availableLabels.map((label) => (
71+
<option key={label.id} value={label.id}>
72+
{label.name}
73+
</option>
74+
))}
75+
</select>
76+
77+
<div className="labels-control-list">
78+
{selected.length === 0 && <div className="labels-control-empty-state">{i18n.noLabels}</div>}
79+
{selected.map((labelSetting) => {
80+
const label = labelsById.get(labelSetting.labelId);
81+
const isDeleted = label === undefined;
82+
83+
return (
84+
<div
85+
key={labelSetting.labelId}
86+
className={classNames("labels-control-item", { "is-deleted": isDeleted })}
87+
>
88+
<div className="labels-control-item-details">
89+
<ObsidianIcon
90+
size="xs"
91+
id="lucide-alert-triangle"
92+
className="label-deleted-warning-icon"
93+
aria-label={i18n.deletedWarning}
94+
/>
95+
<ObsidianIcon
96+
size="xs"
97+
id="tag"
98+
className="label-icon"
99+
data-label-color={label?.color}
100+
/>
101+
<span className="labels-control-item-name">
102+
{labelSetting.labelName}
103+
{isDeleted && ` (${i18n.deleted})`}
104+
</span>
105+
</div>
106+
<button
107+
type="button"
108+
className="labels-control-remove-button clickable-icon"
109+
onClick={() => removeLabel(labelSetting.labelId)}
110+
>
111+
<ObsidianIcon size="xs" id="cross" />
112+
</button>
113+
</div>
114+
);
115+
})}
116+
</div>
117+
</div>
118+
);
119+
};

plugin/src/ui/settings/index.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type TodoistPlugin from "../..";
99
import { type Settings, useSettingsStore } from "../../settings";
1010
import { TokenValidation } from "../../token";
1111
import { AutoRefreshIntervalControl } from "./AutoRefreshIntervalControl";
12+
import { LabelsControl } from "./LabelsControl";
1213
import { ProjectDropdownControl } from "./ProjectDropdownControl";
1314
import { Setting } from "./SettingItem";
1415
import { TokenChecker } from "./TokenChecker";
@@ -218,6 +219,15 @@ const SettingsRoot: React.FC<Props> = ({ plugin }) => {
218219
onChange={mkOptionUpdate("taskCreationDefaultProject")}
219220
/>
220221
</Setting.Root>
222+
<Setting.Root
223+
name={i18n.taskCreation.defaultLabels.label}
224+
description={i18n.taskCreation.defaultLabels.description}
225+
>
226+
<LabelsControl
227+
value={settings.taskCreationDefaultLabels}
228+
onChange={mkOptionUpdate("taskCreationDefaultLabels")}
229+
/>
230+
</Setting.Root>
221231

222232
<h2>{i18n.advanced.header}</h2>
223233
<Setting.Root

plugin/src/ui/settings/styles.scss

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
@import "../../styles/colors.scss";
2+
13
.mod-cta .setting-button-icon {
24
margin-right: 0.5em;
35
}
@@ -52,3 +54,92 @@
5254
color: var(--text-warning);
5355
}
5456
}
57+
58+
.labels-control-container {
59+
display: flex;
60+
flex-direction: column;
61+
gap: 1em;
62+
63+
.labels-control-list {
64+
display: flex;
65+
flex-direction: column;
66+
gap: 0.5em;
67+
font-size: var(--font-ui-small);
68+
}
69+
70+
.labels-control-item {
71+
display: flex;
72+
justify-content: space-between;
73+
align-items: center;
74+
75+
.labels-control-item-details {
76+
display: flex;
77+
align-items: center;
78+
gap: 0.5em;
79+
80+
.labels-control-item-name {
81+
flex: 1;
82+
}
83+
84+
.label-icon {
85+
@each $name, $color in $todoist-colors {
86+
&[data-label-color="#{$name}"] {
87+
color: var(--todoist-#{$name});
88+
}
89+
}
90+
}
91+
92+
.label-deleted-warning-icon {
93+
color: var(--text-warning);
94+
display: none;
95+
}
96+
}
97+
98+
.labels-control-remove-button {
99+
display: flex;
100+
align-items: center;
101+
justify-content: center;
102+
width: 24px;
103+
height: 24px;
104+
border: none;
105+
background: none;
106+
color: var(--text-muted);
107+
cursor: pointer;
108+
margin-left: 1em;
109+
110+
&:hover {
111+
background-color: var(--background-modifier-hover);
112+
color: var(--text-normal);
113+
}
114+
}
115+
116+
&.is-deleted {
117+
.label-icon {
118+
display: none;
119+
}
120+
121+
.label-deleted-warning-icon {
122+
display: block;
123+
}
124+
125+
.labels-control-item-name {
126+
color: var(--text-muted);
127+
}
128+
}
129+
}
130+
131+
.labels-control-empty-state {
132+
text-align: right;
133+
color: var(--text-muted);
134+
font-style: italic;
135+
}
136+
}
137+
138+
// Top align setting items that contain the labels control
139+
.setting-item:has(.labels-control-container) {
140+
align-items: flex-start;
141+
142+
.setting-item-info {
143+
padding-top: 0.25em;
144+
}
145+
}

0 commit comments

Comments
 (0)