Skip to content

Commit f4f519f

Browse files
accorvinclaude
andauthored
feat: add optional help text to field definitions (#34)
Add a helpText property to field definitions that displays an info icon next to field labels. Clicking the icon opens a popover with guidance text that supports **bold** and [links](url) via inline markdown. Surfaces in: person detail, team detail, manager dashboard column headers, field definition settings, and edit modals. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent afdf4ee commit f4f519f

8 files changed

Lines changed: 166 additions & 17 deletions

File tree

docs/DATA-FORMATS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,7 @@ Stores custom field definitions for person-level and team-level fields. Created
471471
"required": false,
472472
"visible": true,
473473
"primaryDisplay": true,
474+
"helpText": "The primary area this person is currently focused on.",
474475
"allowedValues": null,
475476
"optionsRef": null,
476477
"deleted": false,
@@ -488,6 +489,7 @@ Stores custom field definitions for person-level and team-level fields. Created
488489
"required": false,
489490
"visible": true,
490491
"primaryDisplay": false,
492+
"helpText": null,
491493
"allowedValues": null,
492494
"optionsRef": null,
493495
"deleted": false,
@@ -507,6 +509,7 @@ Stores custom field definitions for person-level and team-level fields. Created
507509
- `deleted` supports soft-delete — deleted fields are hidden from the UI but values are preserved.
508510
- `allowedValues` is an array of strings for `constrained` fields (the set of selectable options), or `null` for other field types. Maximum 100 items, each up to 200 characters. When `optionsRef` is set, `allowedValues` is `null` in storage and resolved at runtime from the referenced field option set.
509511
- `optionsRef` is an optional string referencing a named field option set (e.g., `"components"`). When set, the field's allowed values are sourced dynamically from `data/team-data/field-options/<optionsRef>.json` instead of from the static `allowedValues` array. The `GET /structure/field-definitions` API response resolves `optionsRef` fields by injecting the option values into `allowedValues` (with a `_resolvedFromOptions: true` flag). Defaults to `null`.
512+
- `helpText` is an optional string (or `null`) providing guidance displayed to users via an info icon. Supports a minimal inline-markdown subset: `**bold**` and `[text](https://...)` links. Defaults to `null`.
510513
- At most one person field can have `primaryDisplay: true`.
511514

512515
## Field Options — `data/team-data/field-options/<name>.json`

fixtures/team-data/field-definitions.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"required": false,
99
"visible": true,
1010
"primaryDisplay": true,
11+
"helpText": "The primary area this person is currently focused on. This could be a **project name**, a **technical domain**, or a [team objective](https://example.com). Update this when focus shifts significantly.",
1112
"allowedValues": null,
1213
"deleted": false,
1314
"order": 0,
@@ -22,6 +23,7 @@
2223
"required": false,
2324
"visible": true,
2425
"primaryDisplay": false,
26+
"helpText": null,
2527
"allowedValues": null,
2628
"optionsRef": "component",
2729
"deleted": false,
@@ -37,6 +39,7 @@
3739
"required": false,
3840
"visible": true,
3941
"primaryDisplay": false,
42+
"helpText": null,
4043
"allowedValues": ["Python", "Go", "Rust", "JavaScript", "Java", "ML/AI"],
4144
"deleted": false,
4245
"order": 2,
@@ -51,6 +54,7 @@
5154
"required": true,
5255
"visible": true,
5356
"primaryDisplay": false,
57+
"helpText": null,
5458
"allowedValues": ["Backend", "Frontend", "Full Stack", "DevOps", "ML Engineer", "QE"],
5559
"deleted": false,
5660
"order": 3,
@@ -67,6 +71,7 @@
6771
"required": false,
6872
"visible": true,
6973
"primaryDisplay": false,
74+
"helpText": null,
7075
"allowedValues": null,
7176
"deleted": false,
7277
"order": 0,
@@ -81,6 +86,7 @@
8186
"required": false,
8287
"visible": true,
8388
"primaryDisplay": false,
89+
"helpText": null,
8490
"allowedValues": ["Active", "Forming", "Sunset"],
8591
"deleted": false,
8692
"order": 1,
@@ -95,6 +101,7 @@
95101
"required": false,
96102
"visible": true,
97103
"primaryDisplay": false,
104+
"helpText": null,
98105
"allowedValues": null,
99106
"optionsRef": "component",
100107
"deleted": false,

modules/team-tracker/client/components/FieldDefinitionManager.vue

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { ref, computed, onMounted, watch } from 'vue'
33
import { apiRequest } from '@shared/client/services/api.js'
44
import { useFieldDefinitions } from '@shared/client/composables/useFieldDefinitions'
5+
import FieldHelpText from './FieldHelpText.vue'
56
67
const { definitions, loading, demoToast, fetchDefinitions, createField, updateField, deleteField } = useFieldDefinitions()
78
@@ -12,6 +13,7 @@ const newFieldType = ref('free-text')
1213
const newFieldMultiValue = ref(false)
1314
const newFieldOptions = ref([])
1415
const newOptionInput = ref('')
16+
const newFieldHelpText = ref('')
1517
const newFieldOptionsSource = ref('inline')
1618
const newFieldOptionsRef = ref('')
1719
const availableOptionSets = ref([])
@@ -55,6 +57,7 @@ function openCreateModal() {
5557
newFieldLabel.value = ''
5658
newFieldType.value = 'free-text'
5759
newFieldMultiValue.value = false
60+
newFieldHelpText.value = ''
5861
newFieldOptions.value = []
5962
newOptionInput.value = ''
6063
newFieldOptionsSource.value = 'inline'
@@ -93,6 +96,9 @@ async function handleCreate() {
9396
label: newFieldLabel.value.trim(),
9497
type: newFieldType.value
9598
}
99+
if (newFieldHelpText.value.trim()) {
100+
body.helpText = newFieldHelpText.value.trim()
101+
}
96102
if (newFieldType.value === 'constrained') {
97103
body.multiValue = newFieldMultiValue.value
98104
if (newFieldOptionsSource.value === 'shared') {
@@ -109,11 +115,13 @@ async function handleCreate() {
109115
}
110116
111117
const editType = ref('')
118+
const editHelpText = ref('')
112119
113120
function startEdit(field) {
114121
editingFieldId.value = field.id
115122
editLabel.value = field.label
116123
editType.value = field.type
124+
editHelpText.value = field.helpText || ''
117125
}
118126
119127
async function saveEdit(fieldId) {
@@ -125,6 +133,10 @@ async function saveEdit(fieldId) {
125133
if (originalField && editType.value !== originalField.type) {
126134
updates.type = editType.value
127135
}
136+
const newHelp = editHelpText.value.trim() || null
137+
if (newHelp !== (originalField?.helpText || null)) {
138+
updates.helpText = newHelp
139+
}
128140
await updateField(activeTab.value, fieldId, updates)
129141
editingFieldId.value = null
130142
} catch (e) {
@@ -228,17 +240,29 @@ async function toggleVisibility(field) {
228240
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
229241
<tr v-for="field in activeFields" :key="field.id" :class="['hover:bg-gray-50 dark:hover:bg-gray-700/50', editingFieldId === field.id ? 'bg-blue-50 dark:bg-blue-900/20' : '']">
230242
<td class="px-4 py-3 text-sm whitespace-nowrap">
231-
<div v-if="editingFieldId === field.id" class="flex items-center gap-2">
232-
<input
233-
v-model="editLabel"
234-
class="block flex-1 rounded border-gray-300 dark:border-gray-600 shadow-sm text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
235-
@keyup.enter="saveEdit(field.id)"
243+
<div v-if="editingFieldId === field.id" class="space-y-2">
244+
<div class="flex items-center gap-2">
245+
<input
246+
v-model="editLabel"
247+
class="block flex-1 rounded border-gray-300 dark:border-gray-600 shadow-sm text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
248+
@keyup.enter="saveEdit(field.id)"
249+
@keyup.escape="editingFieldId = null"
250+
>
251+
<button class="px-2.5 py-1 text-xs font-medium text-white bg-primary-600 rounded hover:bg-primary-700 transition-colors" @click="saveEdit(field.id)">Save</button>
252+
<button class="px-2.5 py-1 text-xs font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors" @click="editingFieldId = null">Cancel</button>
253+
</div>
254+
<textarea
255+
v-model="editHelpText"
256+
rows="2"
257+
class="block w-full rounded border-gray-300 dark:border-gray-600 shadow-sm text-xs bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
258+
placeholder="Help text (optional) — supports **bold** and [links](https://...)"
236259
@keyup.escape="editingFieldId = null"
237-
>
238-
<button class="px-2.5 py-1 text-xs font-medium text-white bg-primary-600 rounded hover:bg-primary-700 transition-colors" @click="saveEdit(field.id)">Save</button>
239-
<button class="px-2.5 py-1 text-xs font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors" @click="editingFieldId = null">Cancel</button>
260+
></textarea>
261+
</div>
262+
<div v-else>
263+
<span class="font-medium text-gray-900 dark:text-gray-100">{{ field.label }}</span>
264+
<FieldHelpText :text="field.helpText" size="xs" />
240265
</div>
241-
<span v-else class="font-medium text-gray-900 dark:text-gray-100">{{ field.label }}</span>
242266
</td>
243267
<td class="px-4 py-3 text-sm whitespace-nowrap text-gray-600 dark:text-gray-400">
244268
<template v-if="editingFieldId === field.id">
@@ -310,6 +334,15 @@ async function toggleVisibility(field) {
310334
<option v-for="t in fieldTypes" :key="t.value" :value="t.value">{{ t.label }}</option>
311335
</select>
312336
</div>
337+
<div>
338+
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Help Text <span class="text-xs text-gray-400 font-normal">(optional)</span></label>
339+
<textarea
340+
v-model="newFieldHelpText"
341+
rows="2"
342+
class="block w-full rounded border-gray-300 shadow-sm text-sm focus:ring-primary-500 focus:border-primary-500"
343+
placeholder="Guidance shown to users when filling in this field. Supports **bold** and [links](https://...)."
344+
></textarea>
345+
</div>
313346
<!-- Allowed values for constrained type -->
314347
<div v-if="newFieldType === 'constrained'" class="space-y-3">
315348
<div>
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<script setup>
2+
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
3+
4+
const props = defineProps({
5+
text: { type: String, default: null },
6+
size: { type: String, default: 'sm' }
7+
})
8+
9+
const showPopover = ref(false)
10+
const triggerEl = ref(null)
11+
const popoverEl = ref(null)
12+
const popoverPos = ref({ top: 0, left: 0 })
13+
14+
const sizeClasses = computed(() =>
15+
props.size === 'xs' ? 'h-3 w-3' : 'h-3.5 w-3.5'
16+
)
17+
18+
/**
19+
* Render a minimal inline-markdown subset to HTML:
20+
* **bold** -> <strong>
21+
* [text](url) -> <a>
22+
* Escapes HTML entities first to prevent XSS.
23+
*/
24+
const renderedHtml = computed(() => {
25+
if (!props.text) return ''
26+
let s = props.text
27+
.replace(/&/g, '&amp;')
28+
.replace(/</g, '&lt;')
29+
.replace(/>/g, '&gt;')
30+
.replace(/"/g, '&quot;')
31+
32+
// **bold**
33+
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
34+
// [text](url) — only allow http(s) URLs
35+
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="text-primary-600 dark:text-primary-400 underline">$1</a>')
36+
// Newlines to <br>
37+
s = s.replace(/\n/g, '<br>')
38+
return s
39+
})
40+
41+
function updatePosition() {
42+
if (!triggerEl.value) return
43+
const rect = triggerEl.value.getBoundingClientRect()
44+
const top = rect.bottom + 6
45+
const left = Math.max(8, rect.left - 100)
46+
const maxLeft = window.innerWidth - 300
47+
popoverPos.value = { top, left: Math.min(left, maxLeft) }
48+
}
49+
50+
function toggle(e) {
51+
e.stopPropagation()
52+
showPopover.value = !showPopover.value
53+
}
54+
55+
watch(showPopover, (val) => {
56+
if (val) nextTick(updatePosition)
57+
})
58+
59+
function onClickOutside(e) {
60+
if (
61+
showPopover.value &&
62+
triggerEl.value && !triggerEl.value.contains(e.target) &&
63+
popoverEl.value && !popoverEl.value.contains(e.target)
64+
) {
65+
showPopover.value = false
66+
}
67+
}
68+
69+
onMounted(() => document.addEventListener('click', onClickOutside, true))
70+
onUnmounted(() => document.removeEventListener('click', onClickOutside, true))
71+
</script>
72+
73+
<template>
74+
<span v-if="text" class="relative inline-flex items-center">
75+
<button
76+
ref="triggerEl"
77+
type="button"
78+
class="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors focus:outline-none"
79+
:title="showPopover ? '' : 'Click for more info'"
80+
@click="toggle"
81+
>
82+
<svg :class="sizeClasses" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
83+
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
84+
</svg>
85+
</button>
86+
<Teleport to="body">
87+
<div
88+
v-if="showPopover"
89+
ref="popoverEl"
90+
class="fixed z-[100] max-w-xs w-72 p-3 text-sm text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg"
91+
:style="{ top: popoverPos.top + 'px', left: popoverPos.left + 'px' }"
92+
>
93+
<div v-html="renderedHtml" class="leading-relaxed [&_strong]:font-semibold"></div>
94+
</div>
95+
</Teleport>
96+
</span>
97+
</template>

modules/team-tracker/client/components/PersonFieldEditor.vue

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ref, computed, watch, inject, onUnmounted } from 'vue'
33
import { useFieldDefinitions } from '@shared/client/composables/useFieldDefinitions'
44
import PersonAutocomplete from './PersonAutocomplete.vue'
55
import ConstrainedAutocomplete from './ConstrainedAutocomplete.vue'
6+
import FieldHelpText from './FieldHelpText.vue'
67
78
const props = defineProps({
89
uid: { type: String, required: true },
@@ -152,8 +153,9 @@ function isPersonRefType(field) {
152153
<div v-for="field in visibleFields" :key="field.id" :class="['group', editingFieldId === field.id ? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md px-2 py-1.5' : '']">
153154
<!-- Edit mode -->
154155
<template v-if="editingFieldId === field.id">
155-
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
156-
{{ field.label }}<span v-if="field.required" class="text-red-500 ml-0.5">*</span>
156+
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1 flex items-center gap-1">
157+
<span>{{ field.label }}<span v-if="field.required" class="text-red-500 ml-0.5">*</span></span>
158+
<FieldHelpText :text="field.helpText" size="xs" />
157159
</div>
158160
<!-- Constrained field (single or multi-value): autocomplete -->
159161
<ConstrainedAutocomplete
@@ -196,7 +198,10 @@ function isPersonRefType(field) {
196198
<path stroke-linecap="round" stroke-linejoin="round" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
197199
</svg>
198200
<div class="flex-1 min-w-0">
199-
<div class="text-[11px] text-gray-400 dark:text-gray-500 leading-tight">{{ field.label }}<span v-if="field.required" class="text-red-500 ml-0.5">*</span></div>
201+
<div class="text-[11px] text-gray-400 dark:text-gray-500 leading-tight flex items-center gap-0.5">
202+
<span>{{ field.label }}<span v-if="field.required" class="text-red-500 ml-0.5">*</span></span>
203+
<FieldHelpText :text="field.helpText" size="xs" />
204+
</div>
200205
<!-- Multi-value display -->
201206
<div v-if="isMultiValue(field)" class="flex flex-wrap gap-1 mt-0.5">
202207
<span

modules/team-tracker/client/components/TeamFieldEditor.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ref, computed, watch, onUnmounted } from 'vue'
33
import { useTeams } from '@shared/client/composables/useTeams'
44
import PersonAutocomplete from './PersonAutocomplete.vue'
55
import ConstrainedAutocomplete from './ConstrainedAutocomplete.vue'
6+
import FieldHelpText from './FieldHelpText.vue'
67
78
const props = defineProps({
89
teamId: { type: String, required: true },
@@ -170,8 +171,9 @@ function isPersonRefType(field) {
170171
editingFieldId === field.id ? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md px-2 py-1' : ''
171172
]"
172173
>
173-
<span :class="inline ? 'text-sm text-gray-400 dark:text-gray-500 shrink-0' : 'text-sm text-gray-600 dark:text-gray-400 w-32 shrink-0'">
174-
{{ field.label }}<span v-if="field.required" class="text-red-500 ml-0.5">*</span>:
174+
<span :class="[inline ? 'text-sm text-gray-400 dark:text-gray-500 shrink-0' : 'text-sm text-gray-600 dark:text-gray-400 w-32 shrink-0', 'inline-flex items-center gap-1']">
175+
<span>{{ field.label }}<span v-if="field.required" class="text-red-500 ml-0.5">*</span>:</span>
176+
<FieldHelpText :text="field.helpText" size="xs" />
175177
</span>
176178
<template v-if="editingFieldId === field.id">
177179
<!-- Constrained field (single or multi-value): autocomplete -->

modules/team-tracker/client/views/ManagerDashboardView.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@
156156
:data-tour="idx === 0 ? 'field-cell' : undefined"
157157
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider"
158158
:class="bulkEditing ? 'text-primary-700 dark:text-primary-300 bg-blue-50 dark:bg-blue-900/30' : 'text-gray-500 dark:text-gray-400'"
159-
>{{ field.label }}</th>
159+
><span class="inline-flex items-center gap-1">{{ field.label }}<FieldHelpText :text="field.helpText" size="xs" /></span></th>
160160
</tr>
161161
</thead>
162162
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
@@ -373,7 +373,7 @@
373373
:key="field.id"
374374
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider"
375375
:class="teamBulkEditing ? 'text-primary-700 dark:text-primary-300 bg-blue-50 dark:bg-blue-900/30' : 'text-gray-500 dark:text-gray-400'"
376-
>{{ field.label }}</th>
376+
><span class="inline-flex items-center gap-1">{{ field.label }}<FieldHelpText :text="field.helpText" size="xs" /></span></th>
377377
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="teamBulkEditing ? 'text-primary-700 dark:text-primary-300 bg-blue-50 dark:bg-blue-900/30' : 'text-gray-500 dark:text-gray-400'">Boards</th>
378378
</tr>
379379
</thead>
@@ -513,6 +513,7 @@ import { isFieldEmpty as _isFieldEmpty, buildExceptionSet, hasException as _hasE
513513
import ConstrainedAutocomplete from '../components/ConstrainedAutocomplete.vue'
514514
import FieldDisplayCell from '../components/FieldDisplayCell.vue'
515515
import FieldEditCell from '../components/FieldEditCell.vue'
516+
import FieldHelpText from '../components/FieldHelpText.vue'
516517
import TeamBoardsDrawer from '../components/TeamBoardsDrawer.vue'
517518
import { useManagerTutorial } from '../composables/useManagerTutorial'
518519

shared/server/field-store.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ async function createFieldDefinition(storage, scope, definition, actorEmail) {
164164
required: definition.required || false,
165165
visible: definition.visible !== false,
166166
primaryDisplay: definition.primaryDisplay || false,
167+
helpText: definition.helpText || null,
167168
allowedValues: definition.allowedValues || null,
168169
optionsRef: definition.optionsRef || null,
169170
deleted: false,
@@ -218,7 +219,7 @@ async function updateFieldDefinition(storage, scope, fieldId, updates, actorEmai
218219

219220
const changes = {};
220221
for (const [k, v] of Object.entries(updates)) {
221-
if (['label', 'type', 'required', 'visible', 'primaryDisplay', 'allowedValues', 'multiValue', 'optionsRef'].includes(k)) {
222+
if (['label', 'type', 'required', 'visible', 'primaryDisplay', 'helpText', 'allowedValues', 'multiValue', 'optionsRef'].includes(k)) {
222223
changes[k] = { old: field[k], new: v };
223224
field[k] = v;
224225
}

0 commit comments

Comments
 (0)