Skip to content

Commit bc69b3d

Browse files
authored
Merge pull request #3460 from obsidian-tasks-group/feat-validate-include-names
feat: Add error-checking of duplicate names of "Includes"
2 parents 0cd3746 + 7ea92d2 commit bc69b3d

File tree

6 files changed

+325
-37
lines changed

6 files changed

+325
-37
lines changed

resources/sample_vaults/Tasks-Demo/.obsidian/plugins/obsidian-tasks-plugin/data.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
{
2-
"includes": {},
2+
"includes": {
3+
"hide_all_dates": "# Hide any values for all 6 date fields\nhide due date\nhide scheduled date\nhide start date\nhide created date\nhide done date\nhide cancelled date",
4+
"hide_buttons": "# Hide postpone, edit and backinks\nhide postpone button\nhide edit button\nhide backlinks",
5+
"hide_other_fields": "# Hide all the non-date fields, keep tags\nhide id\nhide depends on\nhide recurrence rule\nhide on completion\nhide priority",
6+
"just_the_description_and_tags": "# Show only description and any tags\ninclude hide_all_dates\ninclude hide_other_fields\ninclude hide_buttons",
7+
"just_the_description": "# Show only description, not even the tags\ninclude just_the_description_and_tags\nhide tags",
8+
"filter_by_context": "filter by function let fn = (ctx) => task.tags.includes(`#context/${ctx}`); return fn",
9+
"extract_contexts_1": "ctx => task.tags.includes(`#context/${ctx}`)",
10+
"extract_contexts_2": "(ctx => task.tags.includes(`#context/${ctx}`))"
11+
},
312
"globalQuery": "# Exclude templates files:\nroot does not include _meta\n\n# Ignore the sample tasks that demonstrate Theme and Snippet support:\npath does not include Manual Testing/SlrVb's Alternative Checkboxes\npath does not include Styling/Snippet -\npath does not include Styling/Theme -\n",
413
"globalFilter": "#task",
514
"removeGlobalFilter": true,
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
---
2+
TQ_explain: false
3+
TQ_extra_instructions: |-
4+
ignore global query
5+
limit 10
6+
---
7+
# Reuse instructions across the vault
8+
9+
These searches are for experimenting with, and understanding, the new "Includes" facility, which was released in Tasks X.Y.Z.
10+
11+
Includes values can be defined in the Tasks settings.
12+
13+
explain: `INPUT[toggle:TQ_explain]`
14+
15+
## List all the known 'include' values in settings
16+
17+
(assuming there is not one called `xxxx`!)
18+
19+
```tasks
20+
include xxxx
21+
```
22+
23+
## Show all the fields
24+
25+
explain: `INPUT[toggle:TQ_explain]`
26+
27+
```tasks
28+
```
29+
30+
## Hide all the fields
31+
32+
explain: `INPUT[toggle:TQ_explain]`
33+
34+
```tasks
35+
include hide_all_dates
36+
include hide_other_fields
37+
```
38+
39+
## Hide all the Tasks user interface elements
40+
41+
explain: `INPUT[toggle:TQ_explain]`
42+
43+
```tasks
44+
include hide_buttons
45+
```
46+
47+
## Hide everything, including tags
48+
49+
explain: `INPUT[toggle:TQ_explain]`
50+
51+
```tasks
52+
include just_the_description_and_tags
53+
```
54+
55+
## Advanced use: return a function, that takes a parameter from the query source
56+
57+
### Has context 'home' - and group by the Include text - version 1
58+
59+
explain: `INPUT[toggle:TQ_explain]`
60+
61+
```tasks
62+
# For debug/explanatory purposes, show the source of the Include as a group name:
63+
group by function const x = "{{includes.filter_by_context}}"; return x
64+
65+
{{includes.filter_by_context}}('home')
66+
```
67+
68+
### Has context 'home' - and group by the Include text - version 2
69+
70+
explain: `INPUT[toggle:TQ_explain]`
71+
72+
```tasks
73+
# For debug/explanatory purposes, show the source of the Include as a group name:
74+
group by function const x = "{{includes.extract_contexts_1}}"; return x
75+
76+
# includes.extract_contexts_1 value needs to be surrounded by parentheses ():
77+
filter by function return ({{includes.extract_contexts_1}})('home')
78+
```
79+
80+
### Has context 'home' - and group by the Include text - version 3
81+
82+
explain: `INPUT[toggle:TQ_explain]`
83+
84+
```tasks
85+
# For debug/explanatory purposes, show the source of the Include as a group name:
86+
group by function const x = "{{includes.extract_contexts_2}}"; return x
87+
88+
# includes.extract_contexts_1 value has the parentheses, to simplify use:
89+
filter by function {{includes.extract_contexts_2}}('home')
90+
```

src/Config/IncludesSettingsService.ts

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,71 @@
11
import { renameKeyInRecordPreservingOrder } from '../lib/RecordHelpers';
22
import type { IncludesMap } from './Settings';
33

4+
/**
5+
* Represents a map of include keys and their current values
6+
* used during validation
7+
*/
8+
export interface RenamesInProgress {
9+
[originalName: string]: string;
10+
}
11+
12+
export class RenameResult {
13+
public readonly originalName: string;
14+
public readonly isValid: boolean;
15+
public readonly errorMessage: string | null;
16+
17+
constructor(originalName: string, isValid: boolean, errorMessage: string | null) {
18+
this.originalName = originalName;
19+
this.isValid = isValid;
20+
this.errorMessage = errorMessage;
21+
}
22+
}
23+
24+
/**
25+
* Result of validating multiple include values at once
26+
*/
27+
export interface RenameResults {
28+
[originalName: string]: RenameResult;
29+
}
30+
431
export class IncludesSettingsService {
32+
/**
33+
* Validates multiple include names against each other
34+
* @param renames Map of original keys to their current values in UI
35+
* @returns Object mapping each key to its validation result
36+
*/
37+
public validateRenames(renames: RenamesInProgress): RenameResults {
38+
const results: RenameResults = {};
39+
40+
// Check each key against all others
41+
for (const [originalName, newName] of Object.entries(renames)) {
42+
const includesWithOtherPendingRenames: IncludesMap = {};
43+
44+
for (const [otherOriginalName, otherNewName] of Object.entries(renames)) {
45+
// Skip the name being validated to avoid false duplicate matches.
46+
if (otherOriginalName !== originalName) {
47+
includesWithOtherPendingRenames[otherNewName] = '';
48+
}
49+
}
50+
51+
// Pass empty string as keyBeingRenamed since we're not excluding any key from duplicate check
52+
results[originalName] = this.validateRename(includesWithOtherPendingRenames, '', newName);
53+
}
54+
55+
return results;
56+
}
57+
558
/**
659
* Validates if an include name is valid
760
* @param includes The current includes map
861
* @param keyBeingRenamed The key being renamed
9-
* @param proposedName The proposed name to validate
62+
* @param newName The proposed name to validate
1063
* @returns An object with validation result and error message if any
1164
*/
12-
public validateIncludeName(
13-
includes: Readonly<IncludesMap>,
14-
keyBeingRenamed: string,
15-
proposedName: string,
16-
): { isValid: boolean; errorMessage: string | null } {
65+
public validateRename(includes: Readonly<IncludesMap>, keyBeingRenamed: string, newName: string): RenameResult {
1766
// Check for empty name
18-
if (!proposedName || proposedName.trim() === '') {
19-
return {
20-
isValid: false,
21-
errorMessage: 'Include name cannot be empty or all whitespace',
22-
};
67+
if (!newName || newName.trim() === '') {
68+
return new RenameResult(keyBeingRenamed, false, 'Include name cannot be empty or all whitespace');
2369
}
2470

2571
for (const existingKey of Object.keys(includes)) {
@@ -28,15 +74,12 @@ export class IncludesSettingsService {
2874
continue;
2975
}
3076

31-
if (existingKey === proposedName) {
32-
return {
33-
isValid: false,
34-
errorMessage: 'An include with this name already exists',
35-
};
77+
if (existingKey.trim() === newName.trim()) {
78+
return new RenameResult(keyBeingRenamed, false, 'An include with this name already exists');
3679
}
3780
}
3881

39-
return { isValid: true, errorMessage: null };
82+
return new RenameResult(keyBeingRenamed, true, null);
4083
}
4184

4285
/**

src/Config/IncludesSettingsUI.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,9 @@
5151
align-items: unset;
5252
}
5353
}
54+
55+
/* Error state for invalid input */
56+
.tasks-settings .tasks-includes-setting .tasks-includes-key.has-error {
57+
border-color: var(--text-error);
58+
border-width: 2px;
59+
}

src/Config/IncludesSettingsUI.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Setting, TextAreaComponent } from 'obsidian';
22
import type TasksPlugin from '../main';
3-
import { IncludesSettingsService } from './IncludesSettingsService';
3+
import { IncludesSettingsService, type RenamesInProgress } from './IncludesSettingsService';
44
import { type IncludesMap, type Settings, getSettings, updateSettings } from './Settings';
55

66
type RefreshViewCallback = () => void;
@@ -13,6 +13,7 @@ type RefreshViewCallback = () => void;
1313
export class IncludesSettingsUI {
1414
private readonly plugin: TasksPlugin;
1515
private readonly includesSettingsService = new IncludesSettingsService();
16+
private readonly nameFields: Map<string, { inputEl: HTMLInputElement; originalKey: string }> = new Map();
1617

1718
/**
1819
* Creates a new instance of IncludesSettingsUI
@@ -33,6 +34,9 @@ export class IncludesSettingsUI {
3334
const renderIncludes = () => {
3435
includesContainer.empty();
3536

37+
// Clear the input map when re-rendering
38+
this.nameFields.clear();
39+
3640
Object.entries(settings.includes).forEach(([key, value]) => {
3741
this.renderIncludeItem(includesContainer, settings, key, value, renderIncludes);
3842
});
@@ -67,10 +71,16 @@ export class IncludesSettingsUI {
6771
text.setPlaceholder('Name').setValue(key);
6872
text.inputEl.addClass('tasks-includes-key');
6973

74+
// Store reference to this input with its original key
75+
this.nameFields.set(key, { inputEl: text.inputEl, originalKey: key });
76+
7077
let newKey = key;
7178

7279
text.inputEl.addEventListener('input', (e) => {
7380
newKey = (e.target as HTMLInputElement).value;
81+
82+
// Validate all inputs to update any that might be affected
83+
this.validateAllInputs();
7484
});
7585

7686
// Handle renaming an include
@@ -109,6 +119,8 @@ export class IncludesSettingsUI {
109119
});
110120
});
111121

122+
// TODO Add reorder button or facility to drag and drop items
123+
112124
// Add delete button
113125
setting.addExtraButton((btn) => {
114126
btn.setIcon('cross')
@@ -120,6 +132,36 @@ export class IncludesSettingsUI {
120132
});
121133
}
122134

135+
/**
136+
* Validates all input elements and updates their styling
137+
*/
138+
private validateAllInputs() {
139+
// Build the current key-value map for validation
140+
const currentValues: RenamesInProgress = {};
141+
142+
this.nameFields.forEach(({ inputEl, originalKey }) => {
143+
currentValues[originalKey] = inputEl.value;
144+
});
145+
146+
// Get validation results from the service
147+
const validationResults = this.includesSettingsService.validateRenames(currentValues);
148+
149+
// Apply styling based on validation results
150+
this.nameFields.forEach(({ inputEl, originalKey }) => {
151+
const result = validationResults[originalKey];
152+
153+
if (result && !result.isValid) {
154+
inputEl.addClass('has-error');
155+
156+
// Optionally, you could add a title attribute to show the error message on hover
157+
inputEl.title = result.errorMessage || '';
158+
} else {
159+
inputEl.removeClass('has-error');
160+
inputEl.title = '';
161+
}
162+
});
163+
}
164+
123165
/**
124166
* Sets up auto-resizing behaviour for a textarea component
125167
* @param textArea The textarea component to configure
@@ -165,6 +207,7 @@ export class IncludesSettingsUI {
165207
settings: Settings,
166208
refreshView: RefreshViewCallback | null,
167209
) {
210+
// TODO Consider how this relates to the validation code - should it refuse to save settings if validation fails?
168211
// Update the settings in storage
169212
updateSettings({ includes: updatedIncludes });
170213
await this.plugin.saveSettings();

0 commit comments

Comments
 (0)