Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97e6be3afa |
@@ -2440,7 +2440,8 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
|
|||||||
await page.keyboard.type('Fælles aftale om leveringen');
|
await page.keyboard.type('Fælles aftale om leveringen');
|
||||||
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||||
|
|
||||||
// Shared by being filed in a project. Not an access list of its own.
|
// Filing it in a project — the controls live in the Del popover in the footer.
|
||||||
|
await page.locator('.sharewrap > button').click();
|
||||||
await page.getByLabel('Læg i projekt').selectOption({ label: 'Delenoter' });
|
await page.getByLabel('Læg i projekt').selectOption({ label: 'Delenoter' });
|
||||||
await expect(page.getByText('delt med projektets folk')).toBeVisible();
|
await expect(page.getByText('delt med projektets folk')).toBeVisible();
|
||||||
|
|
||||||
@@ -2451,6 +2452,7 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
|
|||||||
// Taken back out, it is the author's alone again.
|
// Taken back out, it is the author's alone again.
|
||||||
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||||
await page.getByRole('button', { name: /Fælles aftale/ }).click();
|
await page.getByRole('button', { name: /Fælles aftale/ }).click();
|
||||||
|
await page.locator('.sharewrap > button').click();
|
||||||
await page.getByLabel('Læg i projekt').selectOption({ label: 'Intet projekt' });
|
await page.getByLabel('Læg i projekt').selectOption({ label: 'Intet projekt' });
|
||||||
await expect(page.getByText('din igen')).toBeVisible();
|
await expect(page.getByText('din igen')).toBeVisible();
|
||||||
|
|
||||||
@@ -2487,10 +2489,12 @@ test('en note kan deles med en person direkte, og dukker op hos dem', async ({ b
|
|||||||
await page.keyboard.type('Ferieplan for hele holdet');
|
await page.keyboard.type('Ferieplan for hele holdet');
|
||||||
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||||
|
|
||||||
const share = page.locator('.people-share');
|
// Sharing lives in a popover behind the Del button in the footer.
|
||||||
|
await page.locator('.sharewrap > button').click();
|
||||||
|
const share = page.locator('.sharepanel');
|
||||||
await expect(share).toBeVisible();
|
await expect(share).toBeVisible();
|
||||||
await share.locator('.addshare select').first().selectOption({ label: 'Sofie' });
|
await share.locator('.addshare select').first().selectOption({ label: 'Sofie' });
|
||||||
await page.getByRole('button', { name: 'Del', exact: true }).click();
|
await share.getByRole('button', { name: 'Tilføj' }).click();
|
||||||
|
|
||||||
// She is on the note now, with the role she was given.
|
// She is on the note now, with the role she was given.
|
||||||
const row = share.locator('.sharelist li').filter({ hasText: 'Sofie' });
|
const row = share.locator('.sharelist li').filter({ hasText: 'Sofie' });
|
||||||
@@ -2981,7 +2985,8 @@ test('en note kan oprettes inde fra et projekt og hører til det med det samme',
|
|||||||
await page.waitForTimeout(1200);
|
await page.waitForTimeout(1200);
|
||||||
|
|
||||||
// Den hører til projektet med det samme: "Læg i projekt" står på projektet,
|
// Den hører til projektet med det samme: "Læg i projekt" står på projektet,
|
||||||
// uden at nogen har valgt det bagefter.
|
// uden at nogen har valgt det bagefter. Kontrollen ligger i Del-popoveren.
|
||||||
|
await page.locator('.sharewrap > button').click();
|
||||||
await expect(page.getByLabel('Læg i projekt')).toHaveValue(/.+/);
|
await expect(page.getByLabel('Læg i projekt')).toHaveValue(/.+/);
|
||||||
const shared = await page.getByLabel('Læg i projekt').evaluate((el) => el.selectedOptions[0].textContent);
|
const shared = await page.getByLabel('Læg i projekt').evaluate((el) => el.selectedOptions[0].textContent);
|
||||||
expect(shared).toContain('Tagprojekt');
|
expect(shared).toContain('Tagprojekt');
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export const da = {
|
|||||||
'notes.sharedBy': 'Delt af {name}',
|
'notes.sharedBy': 'Delt af {name}',
|
||||||
'notes.sharePeople': 'Del med personer',
|
'notes.sharePeople': 'Del med personer',
|
||||||
'notes.sharePick': 'Vælg en person …',
|
'notes.sharePick': 'Vælg en person …',
|
||||||
'notes.shareAdd': 'Del',
|
'notes.shareButton': 'Del',
|
||||||
|
'notes.shareAdd': 'Tilføj',
|
||||||
'notes.shareRoleViewer': 'Kan læse',
|
'notes.shareRoleViewer': 'Kan læse',
|
||||||
'notes.shareRoleEditor': 'Kan rette',
|
'notes.shareRoleEditor': 'Kan rette',
|
||||||
'notes.shareRemove': 'Fjern adgang',
|
'notes.shareRemove': 'Fjern adgang',
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ export const en = {
|
|||||||
'notes.sharedBy': 'Shared by {name}',
|
'notes.sharedBy': 'Shared by {name}',
|
||||||
'notes.sharePeople': 'Share with people',
|
'notes.sharePeople': 'Share with people',
|
||||||
'notes.sharePick': 'Choose someone …',
|
'notes.sharePick': 'Choose someone …',
|
||||||
'notes.shareAdd': 'Share',
|
'notes.shareButton': 'Share',
|
||||||
|
'notes.shareAdd': 'Add',
|
||||||
'notes.shareRoleViewer': 'Can view',
|
'notes.shareRoleViewer': 'Can view',
|
||||||
'notes.shareRoleEditor': 'Can edit',
|
'notes.shareRoleEditor': 'Can edit',
|
||||||
'notes.shareRemove': 'Remove access',
|
'notes.shareRemove': 'Remove access',
|
||||||
|
|||||||
@@ -193,6 +193,26 @@
|
|||||||
let shareBusy = $state(false);
|
let shareBusy = $state(false);
|
||||||
const ownsSelected = $derived(!!selected && selected.created_by === app.user?.id);
|
const ownsSelected = $derived(!!selected && selected.created_by === app.user?.id);
|
||||||
|
|
||||||
|
// The whole of sharing — a project and named people — lives in a popover behind
|
||||||
|
// one button, so the note's footer stays a status and three buttons rather than
|
||||||
|
// a row that runs off its own width.
|
||||||
|
let shareOpen = $state(false);
|
||||||
|
let shareWrap = $state(null);
|
||||||
|
// Closing on a switch of note, and only then: the panel belongs to the note it
|
||||||
|
// was opened on. Tracked by id rather than by the object, so a save that returns
|
||||||
|
// a fresh copy of the same note does not slam the panel shut mid-use.
|
||||||
|
let sharePanelFor = $state(null);
|
||||||
|
$effect(() => {
|
||||||
|
const id = selected?.id ?? null;
|
||||||
|
if (id !== sharePanelFor) {
|
||||||
|
shareOpen = false;
|
||||||
|
sharePanelFor = id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
function onShareOutside(event) {
|
||||||
|
if (shareOpen && shareWrap && !shareWrap.contains(event.target)) shareOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = selected?.id;
|
const id = selected?.id;
|
||||||
// Nulstil ved hvert skift, så den forrige notes folk ikke står et øjeblik
|
// Nulstil ved hvert skift, så den forrige notes folk ikke står et øjeblik
|
||||||
@@ -638,6 +658,13 @@
|
|||||||
|
|
||||||
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
|
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
onclick={onShareOutside}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape' && shareOpen) shareOpen = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="notes">
|
<div class="notes">
|
||||||
<aside>
|
<aside>
|
||||||
<div class="head">
|
<div class="head">
|
||||||
@@ -865,73 +892,105 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Filing a note in a project is one way to share it: everybody who can
|
|
||||||
read the project reads the note. Owner only, because moving a note
|
|
||||||
the owner is not in would take it away from them. -->
|
|
||||||
{#if ownsSelected}
|
|
||||||
<label class="share">
|
|
||||||
<span class="hint">{t('notes.shareWith')}</span>
|
|
||||||
<select value={selected.project_id ?? ''} onchange={(e) => share(e.currentTarget.value)}>
|
|
||||||
<option value="">{t('notes.private')}</option>
|
|
||||||
{#each app.projects.filter((p) => !p.is_inbox) as project (project.id)}
|
|
||||||
<option value={project.id}>{project.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<!-- The other way: hand the note to named people, without a project
|
|
||||||
between. The owner picks who and at what role; the list below is
|
|
||||||
who can already see it. -->
|
|
||||||
<div class="people-share">
|
|
||||||
<span class="hint">{t('notes.sharePeople')}</span>
|
|
||||||
{#if shares.length}
|
|
||||||
<ul class="sharelist">
|
|
||||||
{#each shares as sh (sh.user.id)}
|
|
||||||
<li>
|
|
||||||
<span class="who">
|
|
||||||
<span class="avatar" style="--who: {sh.user.avatar_color}" aria-hidden="true"></span>
|
|
||||||
{sh.user.name}
|
|
||||||
</span>
|
|
||||||
<select
|
|
||||||
value={sh.role}
|
|
||||||
onchange={(e) => changeShareRole(sh.user.id, e.currentTarget.value)}
|
|
||||||
>
|
|
||||||
<option value="viewer">{t('notes.shareRoleViewer')}</option>
|
|
||||||
<option value="editor">{t('notes.shareRoleEditor')}</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
class="unshare"
|
|
||||||
onclick={() => removeShare(sh.user.id)}
|
|
||||||
title={t('notes.shareRemove')}
|
|
||||||
aria-label={t('notes.shareRemove')}>×</button
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{:else}
|
|
||||||
<span class="none">{t('notes.shareNobody')}</span>
|
|
||||||
{/if}
|
|
||||||
{#if shareCandidates.length}
|
|
||||||
<div class="addshare">
|
|
||||||
<select bind:value={sharePick}>
|
|
||||||
<option value="">{t('notes.sharePick')}</option>
|
|
||||||
{#each shareCandidates as person (person.id)}
|
|
||||||
<option value={person.id}>{person.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<select bind:value={shareRole}>
|
|
||||||
<option value="viewer">{t('notes.shareRoleViewer')}</option>
|
|
||||||
<option value="editor">{t('notes.shareRoleEditor')}</option>
|
|
||||||
</select>
|
|
||||||
<button class="button" disabled={!sharePick || shareBusy} onclick={addShare}>
|
|
||||||
{t('notes.shareAdd')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
<!-- All of sharing behind one button. A note has two ways in — a
|
||||||
|
project and named people — and both belong to the owner; the
|
||||||
|
panel gathers them rather than lining them up across the footer. -->
|
||||||
|
{#if ownsSelected}
|
||||||
|
<div class="sharewrap" bind:this={shareWrap}>
|
||||||
|
<button
|
||||||
|
class="button"
|
||||||
|
class:on={shareOpen}
|
||||||
|
aria-expanded={shareOpen}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
onclick={() => (shareOpen = !shareOpen)}
|
||||||
|
>
|
||||||
|
{t('notes.shareButton')}{#if shares.length}<span class="count">{shares.length}</span>{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if shareOpen}
|
||||||
|
<div class="sharepanel" role="dialog" aria-label={t('notes.shareButton')}>
|
||||||
|
<!-- Filing it in a project: everybody who can read the project
|
||||||
|
reads the note. A real <label> around the select, so it is
|
||||||
|
named for a screen reader and not only for the eye. -->
|
||||||
|
<section>
|
||||||
|
<label class="field">
|
||||||
|
<span class="phead">{t('notes.shareWith')}</span>
|
||||||
|
<select
|
||||||
|
aria-label={t('notes.shareWith')}
|
||||||
|
value={selected.project_id ?? ''}
|
||||||
|
onchange={(e) => share(e.currentTarget.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('notes.private')}</option>
|
||||||
|
{#each app.projects.filter((p) => !p.is_inbox) as project (project.id)}
|
||||||
|
<option value={project.id}>{project.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Handing it to named people, without a project between. -->
|
||||||
|
<section>
|
||||||
|
<span class="phead">{t('notes.sharePeople')}</span>
|
||||||
|
{#if shares.length}
|
||||||
|
<ul class="sharelist">
|
||||||
|
{#each shares as sh (sh.user.id)}
|
||||||
|
<li>
|
||||||
|
<span class="who">
|
||||||
|
<span
|
||||||
|
class="avatar"
|
||||||
|
style="--who: {sh.user.avatar_color}"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
{sh.user.name}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={sh.role}
|
||||||
|
onchange={(e) => changeShareRole(sh.user.id, e.currentTarget.value)}
|
||||||
|
>
|
||||||
|
<option value="viewer">{t('notes.shareRoleViewer')}</option>
|
||||||
|
<option value="editor">{t('notes.shareRoleEditor')}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
class="unshare"
|
||||||
|
onclick={() => removeShare(sh.user.id)}
|
||||||
|
title={t('notes.shareRemove')}
|
||||||
|
aria-label={t('notes.shareRemove')}>×</button
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<span class="none">{t('notes.shareNobody')}</span>
|
||||||
|
{/if}
|
||||||
|
{#if shareCandidates.length}
|
||||||
|
<div class="addshare">
|
||||||
|
<select bind:value={sharePick}>
|
||||||
|
<option value="">{t('notes.sharePick')}</option>
|
||||||
|
{#each shareCandidates as person (person.id)}
|
||||||
|
<option value={person.id}>{person.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<select bind:value={shareRole}>
|
||||||
|
<option value="viewer">{t('notes.shareRoleViewer')}</option>
|
||||||
|
<option value="editor">{t('notes.shareRoleEditor')}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
class="button"
|
||||||
|
disabled={!sharePick || shareBusy}
|
||||||
|
onclick={addShare}
|
||||||
|
>
|
||||||
|
{t('notes.shareAdd')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<button class="button" onclick={save}>{t('notes.save')}</button>
|
<button class="button" onclick={save}>{t('notes.save')}</button>
|
||||||
{#if ownsSelected}
|
{#if ownsSelected}
|
||||||
<button class="button danger" onclick={() => remove(selected)}>{t('notes.delete')}</button>
|
<button class="button danger" onclick={() => remove(selected)}>{t('notes.delete')}</button>
|
||||||
@@ -1560,13 +1619,58 @@
|
|||||||
font-family: var(--mono, ui-monospace, monospace);
|
font-family: var(--mono, ui-monospace, monospace);
|
||||||
}
|
}
|
||||||
|
|
||||||
.share {
|
/* Sharing lives in a popover above the Del button, so the footer stays a status
|
||||||
|
and a few buttons rather than a row that runs off its own width. */
|
||||||
|
.sharewrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.on {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sharewrap .count {
|
||||||
|
margin-left: 0.4em;
|
||||||
|
padding: 0 0.45em;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--line-strong);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sharepanel {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + var(--s2));
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
width: 20rem;
|
||||||
|
max-width: 80vw;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
padding: var(--s3);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
|
||||||
|
text-align: left;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sharepanel section,
|
||||||
|
.sharepanel .field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: var(--s1);
|
gap: var(--s1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.share select {
|
.sharepanel .phead {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sharepanel select {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
padding: 2px var(--s1);
|
padding: 2px var(--s1);
|
||||||
}
|
}
|
||||||
@@ -1597,15 +1701,7 @@
|
|||||||
content: none;
|
content: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Del med personer, under the note. */
|
.sharepanel .none {
|
||||||
.people-share {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--s1);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.people-share .none {
|
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
}
|
}
|
||||||
@@ -1673,6 +1769,15 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--s1);
|
gap: var(--s1);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
margin-top: var(--s1);
|
||||||
|
}
|
||||||
|
/* The person picker takes its own row, so the role and the button below it never
|
||||||
|
get squeezed to nothing in the panel's width. */
|
||||||
|
.addshare select:first-child {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
.addshare .button {
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.addshare select {
|
.addshare select {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
|
|||||||
Reference in New Issue
Block a user