Skip to content

Commit 1cc6dfa

Browse files
authored
Merge pull request #3446 from obsidian-tasks-group/edit-includes
feat: First try at editing named reusable task block statements
2 parents 98d4fdd + 6a3f6a9 commit 1cc6dfa

File tree

7 files changed

+223
-21
lines changed

7 files changed

+223
-21
lines changed

src/Config/Settings.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,10 @@ export const TASK_FORMATS = {
5959
} as const;
6060

6161
export type TASK_FORMATS = typeof TASK_FORMATS; // For convenience to make some typing easier
62+
export type IncludesMap = Record<string, string>;
6263

6364
export interface Settings {
64-
includes: Record<string, string>;
65+
includes: IncludesMap;
6566
globalQuery: string;
6667
globalFilter: string;
6768
removeGlobalFilter: boolean;

src/Config/SettingsTab.scss

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,47 @@
7979
margin-top: 0px;
8080
margin-bottom: 0px;
8181
}
82+
83+
/* ---------------------------------------------- */
84+
/* Includes section */
85+
/* ---------------------------------------------- */
86+
87+
/* Container for each setting row */
88+
.tasks-settings .tasks-includes-wrapper {
89+
width: 100%;
90+
}
91+
92+
/* Flex layout for each row */
93+
.tasks-settings .tasks-includes-setting.setting-item {
94+
display: flex;
95+
align-items: flex-start;
96+
gap: 1em;
97+
flex-wrap: wrap;
98+
width: 100%;
99+
}
100+
101+
/* Input for key */
102+
.tasks-settings .tasks-includes-setting .tasks-includes-key {
103+
width: 200px;
104+
flex-shrink: 0;
105+
}
106+
107+
/* Textarea for value */
108+
.tasks-settings .tasks-includes-setting .tasks-includes-value {
109+
flex-grow: 1;
110+
min-width: 300px;
111+
min-height: 3em;
112+
font-family: monospace;
113+
resize: horizontal; // allow horizontal resizing
114+
overflow-x: auto; // allow horizontal scrolling
115+
overflow-y: hidden; // hide vertical scrollbar
116+
white-space: pre; // prevent wrapping
117+
}
118+
119+
/* Responsive: stack in narrow view */
120+
@media (max-width: 600px) {
121+
.tasks-settings .tasks-includes-setting.setting-item {
122+
flex-direction: column;
123+
align-items: stretch;
124+
}
125+
}

src/Config/SettingsTab.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Status } from '../Statuses/Status';
66
import type { StatusCollection } from '../Statuses/StatusCollection';
77
import { createStatusRegistryReport } from '../Statuses/StatusRegistryReport';
88
import { i18n } from '../i18n/i18n';
9+
import { renameKeyInRecordPreservingOrder } from '../lib/RecordHelpers';
910
import * as Themes from './Themes';
1011
import { type HeadingState, TASK_FORMATS } from './Settings';
1112
import { getSettings, isFeatureEnabled, updateGeneralSetting, updateSettings } from './Settings';
@@ -146,6 +147,11 @@ export class SettingsTab extends PluginSettingTab {
146147
}),
147148
);
148149

150+
// ---------------------------------------------------------------------------
151+
new Setting(containerEl).setName('Includes').setHeading();
152+
// ---------------------------------------------------------------------------
153+
this.renderIncludesSettings(containerEl);
154+
149155
// ---------------------------------------------------------------------------
150156
new Setting(containerEl).setName(i18n.t('settings.statuses.heading')).setHeading();
151157
// ---------------------------------------------------------------------------
@@ -609,6 +615,101 @@ export class SettingsTab extends PluginSettingTab {
609615
);
610616
}
611617

618+
private renderIncludesSettings(containerEl: HTMLElement) {
619+
const includesContainer = containerEl.createDiv();
620+
const settings = getSettings();
621+
622+
const renderIncludes = () => {
623+
includesContainer.empty();
624+
625+
Object.entries(settings.includes).forEach(([key, value]) => {
626+
const wrapper = includesContainer.createDiv({ cls: 'tasks-includes-wrapper' });
627+
const setting = new Setting(wrapper);
628+
setting.settingEl.addClass('tasks-includes-setting');
629+
630+
setting
631+
.addText((text) => {
632+
text.setPlaceholder('Name').setValue(key);
633+
text.inputEl.addClass('tasks-includes-key');
634+
635+
let newKey = key;
636+
637+
text.inputEl.addEventListener('input', (e) => {
638+
newKey = (e.target as HTMLInputElement).value;
639+
});
640+
641+
const commitRename = async () => {
642+
if (newKey && newKey !== key) {
643+
const newIncludes = renameKeyInRecordPreservingOrder(settings.includes, key, newKey);
644+
updateSettings({ includes: newIncludes });
645+
await this.plugin.saveSettings();
646+
renderIncludes();
647+
}
648+
};
649+
650+
text.inputEl.addEventListener('blur', commitRename);
651+
text.inputEl.addEventListener('keydown', async (e) => {
652+
if (e.key === 'Enter') {
653+
e.preventDefault();
654+
text.inputEl.blur(); // trigger blur handler
655+
}
656+
});
657+
})
658+
.addTextArea((textArea) => {
659+
textArea.inputEl.addClass('tasks-includes-value');
660+
textArea.setPlaceholder('Query or filter text...').setValue(value);
661+
662+
// Resize to fit content
663+
const resize = () => {
664+
textArea.inputEl.style.height = 'auto'; // reset first
665+
textArea.inputEl.style.height = `${textArea.inputEl.scrollHeight}px`;
666+
};
667+
668+
// Initial resize
669+
resize();
670+
671+
// Resize on input
672+
textArea.inputEl.addEventListener('input', resize);
673+
674+
return textArea.onChange(async (newValue) => {
675+
settings.includes[key] = newValue;
676+
updateSettings({ includes: settings.includes });
677+
await this.plugin.saveSettings();
678+
});
679+
})
680+
.addExtraButton((btn) => {
681+
btn.setIcon('cross')
682+
.setTooltip('Delete')
683+
.onClick(async () => {
684+
delete settings.includes[key];
685+
updateSettings({ includes: settings.includes });
686+
await this.plugin.saveSettings();
687+
renderIncludes();
688+
});
689+
});
690+
});
691+
};
692+
693+
renderIncludes();
694+
695+
new Setting(containerEl).addButton((btn) => {
696+
btn.setButtonText('Add new include')
697+
.setCta()
698+
.onClick(async () => {
699+
const baseKey = 'new_key';
700+
let suffix = 1;
701+
while (Object.prototype.hasOwnProperty.call(settings.includes, `${baseKey}_${suffix}`)) {
702+
suffix++;
703+
}
704+
const newKey = `${baseKey}_${suffix}`;
705+
settings.includes[newKey] = '';
706+
updateSettings({ includes: settings.includes });
707+
await this.plugin.saveSettings();
708+
renderIncludes();
709+
});
710+
});
711+
}
712+
612713
private static renderFolderArray(folders: string[]): string {
613714
return folders.join(',');
614715
}

src/Scripting/QueryContext.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getSettings } from '../Config/Settings';
1+
import { type IncludesMap, getSettings } from '../Config/Settings';
22
import type { Task } from '../Task/Task';
33
import type { TasksFile } from './TasksFile';
44

@@ -25,7 +25,7 @@ export interface QueryContext {
2525
allTasks: Readonly<Task[]>;
2626
searchCache: Record<string, any>; // Added caching capability
2727
};
28-
includes: Record<string, string>;
28+
includes: IncludesMap;
2929
}
3030

3131
/**

src/lib/RecordHelpers.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export function renameKeyInRecordPreservingOrder<T>(
2+
record: Record<string, T>,
3+
oldKey: string,
4+
newKey: string,
5+
): Record<string, T> {
6+
if (oldKey === newKey || !Object.prototype.hasOwnProperty.call(record, oldKey)) {
7+
return { ...record };
8+
}
9+
10+
const newRecord: Record<string, T> = {};
11+
12+
for (const [key, value] of Object.entries(record)) {
13+
if (key === oldKey) {
14+
newRecord[newKey] = value;
15+
} else {
16+
newRecord[key] = value;
17+
}
18+
}
19+
20+
return newRecord;
21+
}

tests/Scripting/Includes.test.ts

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44

55
import moment from 'moment';
6-
import { getSettings, resetSettings, updateSettings } from '../../src/Config/Settings';
6+
import { type IncludesMap, getSettings, resetSettings, updateSettings } from '../../src/Config/Settings';
77
import { Query } from '../../src/Query/Query';
88
import { TasksFile } from '../../src/Scripting/TasksFile';
99

@@ -18,9 +18,15 @@ afterEach(() => {
1818
resetSettings();
1919
});
2020

21+
export function makeIncludes(...entries: [string, string][]): IncludesMap {
22+
return Object.fromEntries(entries);
23+
}
24+
2125
describe('include tests', () => {
2226
it('should accept whole-line include placeholder', () => {
23-
updateSettings({ includes: { not_done: 'not done' } });
27+
updateSettings({
28+
includes: makeIncludes(['not_done', 'not done']),
29+
});
2430

2531
const source = '{{includes.not_done}}';
2632
const query = new Query(source, new TasksFile('stuff.md'));
@@ -31,7 +37,9 @@ describe('include tests', () => {
3137
});
3238

3339
it('should accept whole-line include filter instruction', () => {
34-
updateSettings({ includes: { not_done: 'not done' } });
40+
updateSettings({
41+
includes: makeIncludes(['not_done', 'not done']),
42+
});
3543

3644
const source = 'include not_done';
3745
const query = new Query(source, new TasksFile('stuff.md'));
@@ -43,7 +51,9 @@ describe('include tests', () => {
4351
});
4452

4553
it('should accept whole-line include layout instruction', () => {
46-
updateSettings({ includes: { show_tree: 'show tree' } });
54+
updateSettings({
55+
includes: makeIncludes(['show_tree', 'show tree']),
56+
});
4757

4858
const source = 'include show_tree';
4959
const query = new Query(source, new TasksFile('stuff.md'));
@@ -55,8 +65,9 @@ describe('include tests', () => {
5565
});
5666

5767
it('should accept multi-line include', () => {
58-
updateSettings({ includes: { multi_line: 'scheduled tomorrow\nhide backlink' } });
59-
68+
updateSettings({
69+
includes: makeIncludes(['multi_line', 'scheduled tomorrow\nhide backlink']),
70+
});
6071
const source = 'include multi_line';
6172
const query = new Query(source, new TasksFile('stuff.md'));
6273

@@ -84,10 +95,11 @@ describe('include tests', () => {
8495

8596
it('should support nested include instructions', () => {
8697
updateSettings({
87-
includes: {
88-
inside: 'not done',
89-
out: 'include inside\nhide edit button',
90-
},
98+
includes: makeIncludes(
99+
// Force line break
100+
['inside', 'not done'],
101+
['out', 'include inside\nhide edit button'],
102+
),
91103
});
92104

93105
const source = 'include out';
@@ -105,10 +117,10 @@ describe('include tests', () => {
105117

106118
it('should explain two levels of nested includes', () => {
107119
updateSettings({
108-
includes: {
109-
inside: '(happens this week) AND (starts before today)',
110-
out: 'include inside\nnot done',
111-
},
120+
includes: makeIncludes(
121+
['inside', '(happens this week) AND (starts before today)'],
122+
['out', 'include inside\nnot done'],
123+
),
112124
});
113125

114126
const source = 'include out';
@@ -133,10 +145,11 @@ describe('include tests', () => {
133145

134146
it('should give meaningful error message about included text', () => {
135147
updateSettings({
136-
includes: {
137-
inside: 'apple sauce',
138-
out: 'include inside',
139-
},
148+
includes: makeIncludes(
149+
// Force line break
150+
['inside', 'apple sauce'],
151+
['out', 'include inside'],
152+
),
140153
});
141154

142155
const source = 'include out';

tests/lib/RecordHelpers.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { renameKeyInRecordPreservingOrder } from '../../src/lib/RecordHelpers';
2+
3+
describe('renameKeyInRecordPreservingOrder', () => {
4+
it('should rename a key without changing the order of other keys', () => {
5+
const input = {
6+
a: 'apple',
7+
b: 'banana',
8+
c: 'cherry',
9+
};
10+
11+
const result = renameKeyInRecordPreservingOrder(input, 'b', 'blueberry');
12+
13+
expect(result).toEqual({
14+
a: 'apple',
15+
blueberry: 'banana',
16+
c: 'cherry',
17+
});
18+
19+
// Ensure key order is preserved
20+
expect(Object.keys(result)).toEqual(['a', 'blueberry', 'c']);
21+
});
22+
});

0 commit comments

Comments
 (0)