Skip to content

Commit 14468df

Browse files
authored
Merge pull request #1310 from revisit-studies/codex/issue-1279-tag-creation
Fix participant and task tag creation
2 parents d5170c0 + 15bd363 commit 14468df

11 files changed

Lines changed: 425 additions & 91 deletions

src/analysis/individualStudy/thinkAloud/TextEditor.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,13 @@ export function TextEditor({
4141

4242
const { value: tags, execute: pullTags } = useAsync(getTags, [storageEngine]);
4343

44-
const setTags = useCallback((_tags: Tag[]) => {
45-
if (storageEngine) {
46-
storageEngine.saveTags(_tags, 'text').then(() => pullTags(storageEngine));
44+
const setTags = useCallback(async (_tags: Tag[]) => {
45+
if (!storageEngine) {
46+
return;
4747
}
48+
49+
await storageEngine.saveTags(_tags, 'text');
50+
await pullTags(storageEngine);
4851
}, [pullTags, storageEngine]);
4952

5053
const textRefs = useRef<HTMLTextAreaElement[]>([]);
@@ -116,7 +119,7 @@ export function TextEditor({
116119
}, 1);
117120
});
118121

119-
const editTagCallback = useCallback((oldTag: Tag, newTag: Tag) => {
122+
const editTagCallback = useCallback(async (oldTag: Tag, newTag: Tag) => {
120123
if (!tags) {
121124
return;
122125
}
@@ -125,10 +128,10 @@ export function TextEditor({
125128
const tagsCopy = Array.from(tags);
126129
tagsCopy[tagIndex] = newTag;
127130

128-
setTags(tagsCopy);
131+
await setTags(tagsCopy);
129132
}, [setTags, tags]);
130133

131-
const createTagCallback = useCallback((t: Tag) => { setTags([...(tags || []), t]); }, [setTags, tags]);
134+
const createTagCallback = useCallback((t: Tag) => setTags([...(tags || []), t]), [setTags, tags]);
132135

133136
const addTextRefCallback = useCallback((i: number, ref: HTMLTextAreaElement) => {
134137
textRefs.current[i] = ref;

src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -329,13 +329,20 @@ export function ThinkAloudFooter({
329329
navigateToTask(orderedAnswers[nextIndex].identifier);
330330
}, [currentTrial, navigateToTask, orderedAnswers]);
331331

332-
const setTags = useCallback((_tags: Tag[], type: 'task' | 'participant') => {
333-
if (storageEngine) {
334-
storageEngine.saveTags(_tags, type).then(() => { type === 'task' ? pullTags(storageEngine, type) : pullAllParticipantTags(storageEngine, type); });
332+
const setTags = useCallback(async (_tags: Tag[], type: 'task' | 'participant') => {
333+
if (!storageEngine) {
334+
return;
335+
}
336+
337+
await storageEngine.saveTags(_tags, type);
338+
if (type === 'task') {
339+
await pullTags(storageEngine, type);
340+
} else {
341+
await pullAllParticipantTags(storageEngine, type);
335342
}
336343
}, [pullAllParticipantTags, pullTags, storageEngine]);
337344

338-
const editTaskTagCallback = useCallback((oldTag: Tag, newTag: Tag) => {
345+
const editTaskTagCallback = useCallback(async (oldTag: Tag, newTag: Tag) => {
339346
if (!taskTags) {
340347
return;
341348
}
@@ -344,10 +351,10 @@ export function ThinkAloudFooter({
344351
const tagsCopy = Array.from(taskTags);
345352
tagsCopy[tagIndex] = newTag;
346353

347-
setTags(tagsCopy, 'task');
354+
await setTags(tagsCopy, 'task');
348355
}, [setTags, taskTags]);
349356

350-
const editParticipantTagCallback = useCallback((oldTag: Tag, newTag: Tag) => {
357+
const editParticipantTagCallback = useCallback(async (oldTag: Tag, newTag: Tag) => {
351358
if (!allParticipantTags) {
352359
return;
353360
}
@@ -356,12 +363,12 @@ export function ThinkAloudFooter({
356363
const tagsCopy = Array.from(allParticipantTags);
357364
tagsCopy[tagIndex] = newTag;
358365

359-
setTags(tagsCopy, 'participant');
366+
await setTags(tagsCopy, 'participant');
360367
}, [setTags, allParticipantTags]);
361368

362-
const createTaskTagCallback = useCallback((t: Tag) => { setTags([...(taskTags || []), t], 'task'); }, [setTags, taskTags]);
369+
const createTaskTagCallback = useCallback((t: Tag) => setTags([...(taskTags || []), t], 'task'), [setTags, taskTags]);
363370

364-
const createParticipantTagCallback = useCallback((t: Tag) => { setTags([...(taskTags || []), t], 'participant'); }, [setTags, taskTags]);
371+
const createParticipantTagCallback = useCallback((t: Tag) => setTags([...(allParticipantTags || []), t], 'participant'), [allParticipantTags, setTags]);
365372

366373
useEffect(() => {
367374
const t = transcriptLines ? transcriptLines[jumpedToLine]?.start || 0 : 0;

src/analysis/individualStudy/thinkAloud/TranscriptLine.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { TagSelector } from './tags/TagSelector';
1010

1111
export function TranscriptLine({
1212
annotation, setAnnotation, start, current, end, text, tags, selectedTags, onTextChange, deleteRowCallback, addRowCallback, onSelectTags, addRef, index, editTagCallback, createTagCallback, onClickLine,
13-
} : {annotation: string; setAnnotation: (i: number, s: string) => void; start: number, end: number, current: number, text: string, tags: Tag[], selectedTags: Tag[], onTextChange: (i: number, v: string) => void, deleteRowCallback: (i: number) => void, addRowCallback: (i: number, textIndex: number) => void, onSelectTags: (i: number, t: Tag[]) => void, addRef: (i: number, ref: HTMLTextAreaElement) => void, index: number, editTagCallback: (oldTag: Tag, newTag: Tag) => void, createTagCallback: (t: Tag) => void, onClickLine: (focusedLine: number) => void }) {
13+
} : {annotation: string; setAnnotation: (i: number, s: string) => void; start: number, end: number, current: number, text: string, tags: Tag[], selectedTags: Tag[], onTextChange: (i: number, v: string) => void, deleteRowCallback: (i: number) => void, addRowCallback: (i: number, textIndex: number) => void, onSelectTags: (i: number, t: Tag[]) => void, addRef: (i: number, ref: HTMLTextAreaElement) => void, index: number, editTagCallback: (oldTag: Tag, newTag: Tag) => void | Promise<void>, createTagCallback: (t: Tag) => void | Promise<void>, onClickLine: (focusedLine: number) => void }) {
1414
const [annotationVal, setAnnotationVal] = useState<string>(annotation);
1515

1616
const indexRef = useRef<number>(0);

src/analysis/individualStudy/thinkAloud/tags/AddTagDropdown.tsx

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,32 +8,75 @@ import React, { useCallback, useState } from 'react';
88
import { v4 as uuidFunc } from 'uuid';
99
import { Tag } from '../types';
1010

11+
const normalizeTagName = (tagName: string) => tagName.trim().toLowerCase();
12+
1113
export function AddTagDropdown({
1214
addTagCallback, currentNames, editTag = false, editableTag,
13-
} : {addTagCallback : (tag: Tag) => void, currentNames: string[], editTag?: boolean, editableTag?: Tag}) {
15+
} : {addTagCallback : (tag: Tag) => void | Promise<void>, currentNames: string[], editTag?: boolean, editableTag?: Tag}) {
1416
const [name, setName] = useState<string>(editTag ? editableTag!.name : '');
1517
const [color, setColor] = useState<string>(editTag ? editableTag!.color : '#fd7e14');
18+
const [isSubmitting, setIsSubmitting] = useState(false);
19+
const [submitError, setSubmitError] = useState<string | null>(null);
20+
21+
const trimmedName = name.trim();
22+
const normalizedName = normalizeTagName(name);
23+
const normalizedEditableName = editableTag ? normalizeTagName(editableTag.name) : null;
24+
const duplicateName = normalizedName.length > 0 && currentNames.some((currentName) => {
25+
const normalizedCurrentName = normalizeTagName(currentName);
26+
return normalizedCurrentName === normalizedName
27+
&& (!editTag || normalizedCurrentName !== normalizedEditableName);
28+
});
29+
const unchangedTag = editTag && editableTag
30+
? trimmedName === editableTag.name && color === editableTag.color
31+
: false;
32+
const validationError = duplicateName ? 'Tag with this name already exists' : null;
33+
const submitDisabled = trimmedName.length === 0 || duplicateName || unchangedTag || isSubmitting;
34+
35+
const createTag = useCallback(async () => {
36+
if (submitDisabled) {
37+
return;
38+
}
1639

17-
const createTag = useCallback(() => {
1840
const uuid = uuidFunc();
1941

20-
addTagCallback({ color, name, id: editTag && editableTag ? editableTag.id : uuid });
21-
}, [addTagCallback, color, editTag, editableTag, name]);
42+
setIsSubmitting(true);
43+
setSubmitError(null);
44+
45+
try {
46+
await addTagCallback({ color, name: trimmedName, id: editTag && editableTag ? editableTag.id : uuid });
47+
} catch {
48+
setSubmitError('Unable to save tag. Try again.');
49+
} finally {
50+
setIsSubmitting(false);
51+
}
52+
}, [addTagCallback, color, editTag, editableTag, submitDisabled, trimmedName]);
2253

2354
return (
2455
<Stack gap="xs">
2556
<Group>
2657
<ColorSwatch color={color} />
27-
<TextInput required placeholder="Enter tag name" value={name} onChange={(e) => setName(e.currentTarget.value)} error={currentNames.includes(name) && (!editTag || editableTag!.name !== name) ? 'Tag with this name already exists' : null} />
58+
<TextInput
59+
required
60+
placeholder="Enter tag name"
61+
value={name}
62+
onChange={(e) => {
63+
setName(e.currentTarget.value);
64+
setSubmitError(null);
65+
}}
66+
error={validationError || submitError}
67+
/>
2868
</Group>
2969
<ColorPicker
3070
withPicker={editTag}
3171
style={{ width: '100%' }}
3272
value={color}
33-
onChange={(e) => setColor(e)}
73+
onChange={(e) => {
74+
setColor(e);
75+
setSubmitError(null);
76+
}}
3477
swatches={['#2e2e2e', '#868e96', '#fa5252', '#e64980', '#be4bdb', '#7950f2', '#4c6ef5', '#228be6', '#15aabf', '#12b886', '#40c057', '#82c91e', '#fab005', '#fd7e14']}
3578
/>
36-
<Button disabled={(editTag && color === editableTag!.color) && (name.length === 0 || currentNames.includes(name))} size="compact-sm" onClick={() => createTag()}>{editTag ? 'Edit Tag' : 'Add Tag'}</Button>
79+
<Button disabled={submitDisabled} loading={isSubmitting} size="compact-sm" onClick={createTag}>{editTag ? 'Edit Tag' : 'Add Tag'}</Button>
3780
</Stack>
3881
);
3982
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {
2+
ActionIcon, Box, Popover,
3+
} from '@mantine/core';
4+
import { IconEdit } from '@tabler/icons-react';
5+
import { useState } from 'react';
6+
import { Tag } from '../types';
7+
import { AddTagDropdown } from './AddTagDropdown';
8+
9+
export function EditTagPopover({
10+
tag, currentNames, editTagCallback,
11+
}: {
12+
tag: Tag;
13+
currentNames: string[];
14+
editTagCallback: (oldTag: Tag, newTag: Tag) => void | Promise<void>;
15+
}) {
16+
const [editDialogOpen, setEditDialogOpen] = useState(false);
17+
18+
return (
19+
<Box onClick={(event) => {
20+
event.preventDefault();
21+
event.stopPropagation();
22+
}}
23+
>
24+
<Popover opened={editDialogOpen} onChange={setEditDialogOpen} trapFocus withinPortal={false}>
25+
<Popover.Target>
26+
<ActionIcon
27+
aria-label={`Edit ${tag.name}`}
28+
variant="light"
29+
size="sm"
30+
onClick={() => setEditDialogOpen((open) => !open)}
31+
>
32+
<IconEdit />
33+
</ActionIcon>
34+
</Popover.Target>
35+
<Popover.Dropdown>
36+
<AddTagDropdown
37+
editTag
38+
currentNames={currentNames}
39+
addTagCallback={async (newTag: Tag) => {
40+
await editTagCallback(tag, newTag);
41+
setEditDialogOpen(false);
42+
}}
43+
editableTag={tag}
44+
/>
45+
</Popover.Dropdown>
46+
</Popover>
47+
</Box>
48+
);
49+
}

src/analysis/individualStudy/thinkAloud/tags/TagEditor.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,28 @@ import { useState } from 'react';
66
import { Tag } from '../types';
77
import { AddTagDropdown } from './AddTagDropdown';
88

9-
export function TagEditor({ createTagCallback, tags } : { createTagCallback : (tag: Tag) => void, tags: Tag[] }) {
9+
export function TagEditor({ createTagCallback, tags } : { createTagCallback : (tag: Tag) => void | Promise<void>, tags: Tag[] }) {
1010
const [addDialogOpen, setAddDialogOpen] = useState<boolean>(false);
1111

1212
return (
13-
<Popover trapFocus withinPortal={false}>
13+
<Popover opened={addDialogOpen} onChange={setAddDialogOpen} trapFocus withinPortal={false}>
1414
<Stack justify="center">
1515
<Popover.Target>
16-
<Button style={{ width: '100%' }} variant="light" onClick={() => setAddDialogOpen(!addDialogOpen)}>
16+
<Button style={{ width: '100%' }} variant="light" onClick={() => setAddDialogOpen((open) => !open)}>
1717
Create new tag
1818
</Button>
1919
</Popover.Target>
2020
</Stack>
2121
{tags
2222
? (
2323
<Popover.Dropdown>
24-
<AddTagDropdown currentNames={tags.map((t) => t.name)} addTagCallback={(t: Tag) => { setAddDialogOpen(false); createTagCallback(t); }} />
24+
<AddTagDropdown
25+
currentNames={tags.map((t) => t.name)}
26+
addTagCallback={async (t: Tag) => {
27+
await createTagCallback(t);
28+
setAddDialogOpen(false);
29+
}}
30+
/>
2531
</Popover.Dropdown>
2632
) : <Loader />}
2733
</Popover>

src/analysis/individualStudy/thinkAloud/tags/TagSelector.tsx

Lines changed: 4 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,19 @@
11
import {
22
Group, ColorSwatch, useCombobox, Combobox, Pill, PillsInput, Input,
3-
Popover,
4-
ActionIcon,
5-
Box,
63
CheckIcon,
74
Text,
85
Tooltip,
96
} from '@mantine/core';
10-
import { IconEdit } from '@tabler/icons-react';
117
import { useCallback, useMemo } from 'react';
128
import { Tag } from '../types';
139
import { Pills } from './Pills';
1410
import { TagEditor } from './TagEditor';
15-
import { AddTagDropdown } from './AddTagDropdown';
11+
import { EditTagPopover } from './EditTagPopover';
1612

1713
export function TagSelector({
1814
tags, selectedTags, onSelectTags, disabled = false, tagsEmptyText, createTagCallback, editTagCallback, width,
1915
}: {
20-
tags: Tag[], selectedTags: Tag[], onSelectTags: (t: Tag[]) => void, disabled?: boolean, tagsEmptyText: string, editTagCallback: (oldTag: Tag, newTag: Tag) => void, createTagCallback: (t: Tag) => void, width: number
16+
tags: Tag[], selectedTags: Tag[], onSelectTags: (t: Tag[]) => void, disabled?: boolean, tagsEmptyText: string, editTagCallback: (oldTag: Tag, newTag: Tag) => void | Promise<void>, createTagCallback: (t: Tag) => void | Promise<void>, width: number
2117
}) {
2218
const combobox = useCombobox();
2319

@@ -55,32 +51,7 @@ export function TagSelector({
5551
<Text style={{ width: width - 120 }} truncate="end">{tag.name}</Text>
5652
</Group>
5753
</Tooltip>
58-
<Box onClick={(e) => {
59-
e.preventDefault();
60-
e.stopPropagation();
61-
}}
62-
>
63-
<Popover trapFocus withinPortal={false}>
64-
<Popover.Target>
65-
<ActionIcon
66-
variant="light"
67-
size="sm"
68-
>
69-
<IconEdit />
70-
</ActionIcon>
71-
</Popover.Target>
72-
<Popover.Dropdown>
73-
<AddTagDropdown
74-
editTag
75-
currentNames={tags.map((t) => t.id)}
76-
addTagCallback={(newTag: Tag) => {
77-
editTagCallback(tag, newTag);
78-
}}
79-
editableTag={tag}
80-
/>
81-
</Popover.Dropdown>
82-
</Popover>
83-
</Box>
54+
<EditTagPopover tag={tag} currentNames={tags.map((t) => t.name)} editTagCallback={editTagCallback} />
8455
</Group>
8556
</Combobox.Option>
8657
)), [editTagCallback, selectedTags, tags, width]);
@@ -96,36 +67,7 @@ export function TagSelector({
9667
<Text style={{ width: width - 100 }} truncate="end">{tag.name}</Text>
9768
</Group>
9869
</Tooltip>
99-
<Box onClick={(e) => {
100-
e.preventDefault();
101-
e.stopPropagation();
102-
}}
103-
>
104-
<Popover trapFocus withinPortal={false}>
105-
<Popover.Target>
106-
<ActionIcon
107-
variant="light"
108-
size="sm"
109-
onClick={(e) => {
110-
e.stopPropagation();
111-
e.preventDefault();
112-
}}
113-
>
114-
<IconEdit />
115-
</ActionIcon>
116-
</Popover.Target>
117-
<Popover.Dropdown>
118-
<AddTagDropdown
119-
editTag
120-
currentNames={tags.map((t) => t.id)}
121-
addTagCallback={(newTag: Tag) => {
122-
editTagCallback(tag, newTag);
123-
}}
124-
editableTag={tag}
125-
/>
126-
</Popover.Dropdown>
127-
</Popover>
128-
</Box>
70+
<EditTagPopover tag={tag} currentNames={tags.map((t) => t.name)} editTagCallback={editTagCallback} />
12971
</Group>
13072
</Combobox.Option>
13173
)), [editTagCallback, selectedTags, tags, width]);

0 commit comments

Comments
 (0)