feat(tasks): write a task straight into a section
The box at the top puts things in the project with no section, which is right for capturing. But a project has sections because the work belongs somewhere, and dragging every new task down into the right one is a second gesture for something you already knew when you typed it. Two ways in, because they answer different moments. A field at the foot of each section and each board column — standing there is enough, no syntax to know. And `/section` in quick add, for the box at the top and for Today and Upcoming, where you are not standing in a section at all. The field goes through quick add rather than createTask, so a line written there still parses its dates, priorities and labels. The sigil needed care: '#' and '@' never turn up in a Danish task line by accident and '/' turns up constantly. It has to follow whitespace and be followed immediately by the name, which "km/t", "3/4" and "20 kr / stk" all fail. The bare form is greedy, because "Kunder/Ordrer" is a real section name and the sigil is inside it. A section id from the client is checked against the project the task actually landed in. A `#project` in the same line can move the task elsewhere, and the id would otherwise follow it into a project that section does not belong to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9cc7df69f0
commit
0dbf7a40bf
@@ -1336,6 +1336,62 @@ test('projektets navn brydes ikke midt i et ord', async ({ page }) => {
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A task can be written straight into a section, two ways.
|
||||
*
|
||||
* The box at the top of the page puts things in the project with no section, which
|
||||
* is right for capturing — but a project has sections because the work belongs
|
||||
* somewhere, and dragging every new task down into the right one is a second
|
||||
* gesture for something you already knew when you typed it.
|
||||
*
|
||||
* The field at the foot of a section passes the section it sits in. The sigil is
|
||||
* for the box at the top, and for Today and Upcoming, where you are not standing
|
||||
* in a section at all.
|
||||
*/
|
||||
test('en opgave kan skrives direkte i en sektion @forms', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Risteriet');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Risteriet' })).toBeVisible();
|
||||
|
||||
// A section whose name contains the sigil, because that is the one that would
|
||||
// break: the bare form has to be greedy enough to swallow the whole name.
|
||||
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
||||
await page.getByLabel('Ny sektion').fill('Kunder/Ordrer');
|
||||
await page.getByLabel('Ny sektion').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Kunder/Ordrer' })).toBeVisible();
|
||||
|
||||
const section = page.locator('section').filter({
|
||||
has: page.getByRole('heading', { name: 'Kunder/Ordrer' })
|
||||
});
|
||||
|
||||
// The field at the foot of the section. No sigil — standing there is enough.
|
||||
await section.getByRole('button', { name: '+ Tilføj opgave' }).click();
|
||||
const field = page.getByLabel('Ny opgave i Kunder/Ordrer');
|
||||
await field.fill('Karpenhøj bestilling i morgen p1');
|
||||
await field.press('Enter');
|
||||
|
||||
const landed = section.locator('.row').filter({ hasText: 'Karpenhøj bestilling' });
|
||||
await expect(landed).toBeVisible();
|
||||
// It went through quick add, so the date and the priority were read as well.
|
||||
await expect(landed.getByText('I morgen')).toBeVisible();
|
||||
|
||||
// And the sigil, from the box at the top, which is not standing anywhere.
|
||||
await page.getByLabel('Ny opgave').fill('nye poser /Kunder/Ordrer');
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await expect(section.locator('.row').filter({ hasText: 'nye poser' })).toBeVisible();
|
||||
// The sigil is consumed, not left in the title.
|
||||
await expect(section.getByText('/Kunder/Ordrer')).toHaveCount(0);
|
||||
|
||||
await expect(section.locator('.row')).toHaveCount(2);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The sidebar never scrolls sideways.
|
||||
*
|
||||
|
||||
+2
-1
@@ -267,7 +267,8 @@ export const api = {
|
||||
reopenTask: (id) => post(`/tasks/${id}/reopen`),
|
||||
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
|
||||
|
||||
quickAdd: (text, projectId) => post('/tasks/quick-add', { text, project_id: projectId }),
|
||||
quickAdd: (text, projectId, sectionId) =>
|
||||
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
|
||||
quickAddPreview: (text, signal) =>
|
||||
get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }),
|
||||
|
||||
|
||||
@@ -28,6 +28,23 @@
|
||||
let adding = $state(false);
|
||||
let name = $state('');
|
||||
|
||||
/** Same as the list view's: through quick add, so a date or a p1 still parses. */
|
||||
let addingIn = $state(null);
|
||||
let newInSection = $state('');
|
||||
|
||||
async function addToSection(event, sectionId) {
|
||||
event.preventDefault();
|
||||
const text = newInSection.trim();
|
||||
if (!text) return;
|
||||
newInSection = '';
|
||||
addingIn = null;
|
||||
try {
|
||||
app.upsert(await api.quickAdd(text, project.id, sectionId));
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function addSection(event) {
|
||||
event.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
@@ -147,6 +164,35 @@
|
||||
<p class="empty">{t('view.emptySection')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- At the foot of the column, where the next card would go. A board's
|
||||
columns *are* the sections, so this is the one place where "add a
|
||||
task here" needs no explanation at all. -->
|
||||
{#if canEdit}
|
||||
<!-- `column`, which is what the each binds. The unsectioned column has
|
||||
an empty id, and that is exactly what quick add wants for "in this
|
||||
project, no section". -->
|
||||
{#if addingIn === column.id}
|
||||
<form class="add-task" onsubmit={(e) => addToSection(e, column.id)}>
|
||||
<input
|
||||
bind:value={newInSection}
|
||||
use:focusOnMount
|
||||
placeholder={t('view.addTaskHere')}
|
||||
aria-label={t('view.addTaskIn', { name: column.name })}
|
||||
onblur={() => !newInSection.trim() && (addingIn = null)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (addingIn = null)}
|
||||
/>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
class="add-task-open"
|
||||
onclick={() => {
|
||||
addingIn = column.id;
|
||||
newInSection = '';
|
||||
}}>{t('view.addTask')}</button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
@@ -305,6 +351,36 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.add-task-open {
|
||||
align-self: flex-start;
|
||||
padding: var(--s2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.column:hover .add-task-open,
|
||||
.add-task-open:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.add-task-open:hover {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.add-task input {
|
||||
width: 100%;
|
||||
padding: var(--s2);
|
||||
background: var(--ground);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: var(--s3) var(--s2);
|
||||
|
||||
@@ -601,5 +601,9 @@ export const da = {
|
||||
'project.more': 'Flere handlinger',
|
||||
'project.removeMember': 'Fjern {name}',
|
||||
|
||||
'nav.closeMenu': 'Luk menu'
|
||||
'nav.closeMenu': 'Luk menu',
|
||||
|
||||
'view.addTask': '+ Tilføj opgave',
|
||||
'view.addTaskHere': 'Skriv en opgave — dato, p1 og @etiket virker her også',
|
||||
'view.addTaskIn': 'Ny opgave i {name}'
|
||||
};
|
||||
|
||||
@@ -596,5 +596,9 @@ export const en = {
|
||||
'project.more': 'More actions',
|
||||
'project.removeMember': 'Remove {name}',
|
||||
|
||||
'nav.closeMenu': 'Close menu'
|
||||
'nav.closeMenu': 'Close menu',
|
||||
|
||||
'view.addTask': '+ Add a task',
|
||||
'view.addTaskHere': 'Write a task — a date, p1 and @label work here too',
|
||||
'view.addTaskIn': 'New task in {name}'
|
||||
};
|
||||
|
||||
@@ -277,6 +277,36 @@
|
||||
let renamingSection = $state(null);
|
||||
let sectionName = $state('');
|
||||
|
||||
/**
|
||||
* A task written straight into a section.
|
||||
*
|
||||
* The quick-add box at the top of the page puts things in the project with no
|
||||
* section, which is right for "capture it now" — but a project with sections has
|
||||
* them because the work belongs somewhere, and dragging every new task down into
|
||||
* the right one is a second gesture for something you already knew when you
|
||||
* typed it.
|
||||
*
|
||||
* It goes through quick add rather than createTask, so a line written here still
|
||||
* parses its dates, priorities and labels. The section is passed rather than
|
||||
* written, so `/Produktion` is not needed when you are already standing in it.
|
||||
*/
|
||||
let addingIn = $state(null);
|
||||
let newInSection = $state('');
|
||||
|
||||
async function addToSection(event, sectionId) {
|
||||
event.preventDefault();
|
||||
const text = newInSection.trim();
|
||||
if (!text) return;
|
||||
newInSection = '';
|
||||
addingIn = null;
|
||||
try {
|
||||
const created = await api.quickAdd(text, project.id, sectionId);
|
||||
app.upsert(created);
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function addSection(event) {
|
||||
event.preventDefault();
|
||||
const name = sectionName.trim();
|
||||
@@ -684,6 +714,29 @@
|
||||
{#if !tasks.length}
|
||||
<p class="empty">{t('view.emptySection')}</p>
|
||||
{/if}
|
||||
|
||||
{#if canEdit}
|
||||
{#if addingIn === section.id}
|
||||
<form class="add-task" onsubmit={(e) => addToSection(e, section.id)}>
|
||||
<input
|
||||
bind:value={newInSection}
|
||||
use:focusOnMount
|
||||
placeholder={t('view.addTaskHere')}
|
||||
aria-label={t('view.addTaskIn', { name: section.name })}
|
||||
onblur={() => !newInSection.trim() && (addingIn = null)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (addingIn = null)}
|
||||
/>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
class="add-task-open"
|
||||
onclick={() => {
|
||||
addingIn = section.id;
|
||||
newInSection = '';
|
||||
}}>{t('view.addTask')}</button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
@@ -1267,6 +1320,38 @@
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Quiet until wanted. A section that is not being added to should read as a
|
||||
heading and its rows, not as a form. */
|
||||
.add-task-open {
|
||||
align-self: flex-start;
|
||||
padding: var(--s2) 0 var(--s2) var(--s2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
section:hover .add-task-open,
|
||||
.add-task-open:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.add-task-open:hover {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.add-task input {
|
||||
width: 100%;
|
||||
padding: var(--s2);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--accent);
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.add-section {
|
||||
margin-top: var(--s5);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user