Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 90 additions & 24 deletions frontend/src/components/HomeComponents/Tasks/Tasks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export const Tasks = (
due: '',
tags: [] as string[],
});
const [isCreatingNewProject, setIsCreatingNewProject] = useState(false);
const [isAddTaskOpen, setIsAddTaskOpen] = useState(false);
const [_isDialogOpen, setIsDialogOpen] = useState(false);
const [tagInput, setTagInput] = useState('');
Expand Down Expand Up @@ -286,6 +287,12 @@ export const Tasks = (
fetchTasksForEmail();
}, [props.email]);

useEffect(() => {
if (!isAddTaskOpen) {
setIsCreatingNewProject(false);
}
}, [isAddTaskOpen]);

syncTasksWithTwAndDb = useCallback(async () => {
try {
const { email: user_email, encryptionSecret, UUID } = props;
Expand All @@ -308,8 +315,16 @@ export const Tasks = (
.where('email')
.equals(user_email)
.toArray();
setTasks(sortTasksById(updatedTasks, 'desc'));
setTempTasks(sortTasksById(updatedTasks, 'desc'));
const sortedTasks = sortTasksById(updatedTasks, 'desc');
setTasks(sortedTasks);
setTempTasks(sortedTasks);

// Update unique projects after a successful sync so the Project dropdown is populated
const projectsSet = new Set(sortedTasks.map((task) => task.project));
const filteredProjects = Array.from(projectsSet)
.filter((project) => project !== '')
.sort((a, b) => (a > b ? 1 : -1));
setUniqueProjects(filteredProjects);
});

// Store last sync timestamp using hashed key
Expand Down Expand Up @@ -960,7 +975,7 @@ export const Tasks = (
</span>
your tasks
</h3>
<div className="hidden sm:flex flex-row w-full items-center gap-2 md:gap-4">
<div className="sm:flex flex-row w-full items-center gap-2 md:gap-4">
<Input
id="search"
type="text"
Expand All @@ -977,7 +992,7 @@ export const Tasks = (
options={uniqueProjects}
selectedValues={selectedProjects}
onSelectionChange={setSelectedProjects}
className="flex-1 min-w-[140px]"
className="hidden sm:flex-1 min-w-[140px]"
icon={<Key lable="p" />}
/>
<MultiSelectFilter
Expand All @@ -986,7 +1001,7 @@ export const Tasks = (
options={status}
selectedValues={selectedStatuses}
onSelectionChange={setSelectedStatuses}
className="flex-1 min-w-[140px]"
className="hidden sm:flex-1 min-w-[140px]"
icon={<Key lable="s" />}
/>
<MultiSelectFilter
Expand All @@ -995,7 +1010,7 @@ export const Tasks = (
options={uniqueTags}
selectedValues={selectedTags}
onSelectionChange={setSelectedTags}
className="flex-1 min-w-[140px]"
className="hidden sm:flex-1 min-w-[140px]"
icon={<Key lable="t" />}
/>
<div className="pr-2">
Expand Down Expand Up @@ -1075,25 +1090,76 @@ export const Tasks = (
</div>

<div className="grid grid-cols-4 items-center gap-4">
<Label
htmlFor="description"
className="text-right"
>
<Label htmlFor="project" className="text-right">
Project
</Label>
<Input
id="project"
name="project"
type=""
value={newTask.project}
onChange={(e) =>
setNewTask({
...newTask,
project: e.target.value,
})
}
className="col-span-3"
/>
<div className="col-span-3 space-y-2">
<Select
value={
isCreatingNewProject
? ''
: newTask.project || ''
}
onValueChange={(value: string) => {
if (value === '') {
// User selected "create new project" option
setIsCreatingNewProject(true);
setNewTask({ ...newTask, project: '' });
} else {
// User selected an existing project
setIsCreatingNewProject(false);
setNewTask({
...newTask,
project: value,
});
}
}}
>
<SelectTrigger id="project">
<SelectValue
placeholder={
uniqueProjects.length
? 'Select a project'
: 'No projects yet'
}
/>
</SelectTrigger>
<SelectContent>
{uniqueProjects.map((project: string) => (
<SelectItem
key={project}
value={project}
data-testid={`project-option-${project}`}
>
{project}
</SelectItem>
))}
<SelectItem
value=""
data-testid="project-option-create"
>
+ Create new project…
</SelectItem>
</SelectContent>
</Select>
{isCreatingNewProject && (
<Input
id="project-name"
name="project"
placeholder="New project name"
value={newTask.project}
autoFocus
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) =>
setNewTask({
...newTask,
project: e.target.value,
})
}
/>
)}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="due" className="text-right">
Expand Down Expand Up @@ -1191,7 +1257,7 @@ export const Tasks = (
</DialogContent>
</Dialog>
</div>
<div className="flex flex-col items-end gap-2">
<div className="hidden sm:flex flex-col items-end gap-2">
<Button
id="sync-task"
variant="outline"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,32 @@ jest.mock('@/components/ui/multi-select', () => ({
)),
}));

jest.mock('@/components/ui/select', () => {
return {
Select: ({ children, onValueChange, value }: any) => (
<select
data-testid="project-select"
value={value}
onChange={(e) => onValueChange?.(e.target.value)}
>
{children}
</select>
),
SelectTrigger: ({ children }: any) => <>{children}</>,
SelectValue: ({ placeholder }: any) => (
<option value="" disabled hidden>
{placeholder}
</option>
),
SelectContent: ({ children }: any) => <>{children}</>,
SelectItem: ({ value, children, ...props }: any) => (
<option value={value} {...props}>
{children}
</option>
),
};
});

jest.mock('../../BottomBar/BottomBar', () => {
return jest.fn(() => <div>Mocked BottomBar</div>);
});
Expand Down Expand Up @@ -421,4 +447,42 @@ describe('Tasks Component', () => {
const firstRow = screen.getAllByRole('row')[1];
expect(within(firstRow).getByText('Task 1')).toBeInTheDocument();
});

test('project dropdown lists existing projects and create-new option', async () => {
render(<Tasks {...mockProps} />);

expect(await screen.findByText('Task 1')).toBeInTheDocument();

const addTaskButton = screen.getByRole('button', { name: /add task/i });
fireEvent.click(addTaskButton);

const projectSelect = await screen.findByTestId('project-select');
expect(
within(projectSelect).getByText('Select a project')
).toBeInTheDocument();
expect(within(projectSelect).getByText('Engineering')).toBeInTheDocument();
expect(within(projectSelect).getByText('ProjectA')).toBeInTheDocument();
expect(
within(projectSelect).getByText('+ Create new project…')
).toBeInTheDocument();
});

test('selecting "+ Create new project…" reveals inline input', async () => {
render(<Tasks {...mockProps} />);

expect(await screen.findByText('Task 1')).toBeInTheDocument();

fireEvent.click(screen.getByRole('button', { name: /add task/i }));

const projectSelect = await screen.findByTestId('project-select');
fireEvent.change(projectSelect, { target: { value: '' } }); // Empty string triggers "create new project" mode

const newProjectInput =
await screen.findByPlaceholderText('New project name');
fireEvent.change(newProjectInput, {
target: { value: 'My Fresh Project' },
});

expect(newProjectInput).toHaveValue('My Fresh Project');
});
});
61 changes: 53 additions & 8 deletions production/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,63 @@ services:
volumes:
- ./backend/data:/app/data

# syncserver:
# image: ghcr.io/gothenburgbitfactory/taskchampion-sync-server:latest
# ports:
# - "8080:8080"
# networks:
# - tasknetwork
# healthcheck:
# test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
# interval: 30s
# timeout: 10s
# retries: 3
# volumes:
# - ./syncserver/data:/data
# environment:
# - DATA_DIR=/data

mkdir:
image: caddy:2-alpine
command: |
/bin/sh -c "
mkdir -p /data/caddy/data /data/caddy/config /data/syncserver/taskchampion-sync-server"
volumes:
- type: volume
source: data
target: /data
read_only: false
volume:
nocopy: true
networks:
- tasknetwork

syncserver:
image: ghcr.io/gothenburgbitfactory/taskchampion-sync-server:latest
ports:
- "8080:8080"
image: ghcr.io/gothenburgbitfactory/taskchampion-sync-server:0.7.1
restart: unless-stopped
networks:
- tasknetwork
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
ports:
- "8080:8080"
environment:
- "RUST_LOG=info"
- "DATA_DIR=/var/lib/taskchampion-sync-server/data"
- "LISTEN=0.0.0.0:8080"
volumes:
- type: volume
source: data
target: /var/lib/taskchampion-sync-server/data
read_only: false
volume:
nocopy: true
subpath: syncserver/taskchampion-sync-server
depends_on:
mkdir:
condition: service_completed_successfully

networks:
tasknetwork:
driver: bridge

volumes:
data:
Loading