Merge pull request 'Mention teammates from active Today progress updates' (#1065) from timmy/1064-today-progress-mentions into main
This commit is contained in:
commit
339baab7ee
|
|
@ -40,10 +40,10 @@ identity and posts one ordered Markdown evidence comment. After a partial failur
|
||||||
the first unconfirmed image instead of duplicating the issue or earlier uploads. The installed PWA
|
the first unconfirmed image instead of duplicating the issue or earlier uploads. The installed PWA
|
||||||
Share Target accepts the same bounded multi-image bundle through sign-in continuation.
|
Share Target accepts the same bounded multi-image bundle through sign-in continuation.
|
||||||
Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
||||||
issue-comment API. In issue, pull-request, and unread-update conversations, typing
|
issue-comment API. In issue, pull-request, unread-update, and active Today progress
|
||||||
at least two characters after `@` offers repository-scoped teammate suggestions;
|
conversations, typing at least two characters after `@` offers repository-scoped
|
||||||
touch or keyboard selection inserts the login without leaving the draft. Mention
|
teammate suggestions; touch or keyboard selection inserts the login without leaving
|
||||||
lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
|
the draft. Mention lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
|
||||||
creation and assignment permission. A fresh capture never silently targets the first repository:
|
creation and assignment permission. A fresh capture never silently targets the first repository:
|
||||||
the operator must explicitly choose one, either from the paginated browser or through the bounded
|
the operator must explicitly choose one, either from the paginated browser or through the bounded
|
||||||
authenticated repository search. Search results include only visible repository identities, stale
|
authenticated repository search. Search results include only visible repository identities, stale
|
||||||
|
|
|
||||||
|
|
@ -497,22 +497,19 @@
|
||||||
{headers:{Accept:'application/json'}},
|
{headers:{Accept:'application/json'}},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const issueMentions = createMentionComposer({
|
const [issueMentions, pullMentions, updateMentions, todayProgressMentions] = [
|
||||||
textarea:qs('#issue-comment'), listbox:qs('#issue-comment-mentions'),
|
['issue-comment',()=>selectedIssue?.repository],
|
||||||
status:qs('#issue-comment-mention-status'), getRepository:()=>selectedIssue?.repository,
|
['pull-comment',()=>selectedPull?.repository],
|
||||||
loadCandidates:loadMentionCandidates,
|
['update-reply',()=>selectedUpdate?.repository],
|
||||||
|
['today-progress-body',()=>currentTodayProgressTarget()?.repository],
|
||||||
|
].map(([id,getRepository]) => {
|
||||||
|
const controller = createMentionComposer({
|
||||||
|
textarea:qs('#'+id), listbox:qs('#'+id+'-mentions'),
|
||||||
|
status:qs('#'+id+'-mention-status'), getRepository, loadCandidates:loadMentionCandidates,
|
||||||
});
|
});
|
||||||
const pullMentions = createMentionComposer({
|
controller.start();
|
||||||
textarea:qs('#pull-comment'), listbox:qs('#pull-comment-mentions'),
|
return controller;
|
||||||
status:qs('#pull-comment-mention-status'), getRepository:()=>selectedPull?.repository,
|
|
||||||
loadCandidates:loadMentionCandidates,
|
|
||||||
});
|
});
|
||||||
const updateMentions = createMentionComposer({
|
|
||||||
textarea:qs('#update-reply'), listbox:qs('#update-reply-mentions'),
|
|
||||||
status:qs('#update-reply-mention-status'), getRepository:()=>selectedUpdate?.repository,
|
|
||||||
loadCandidates:loadMentionCandidates,
|
|
||||||
});
|
|
||||||
[issueMentions, pullMentions, updateMentions].forEach(controller => controller.start());
|
|
||||||
|
|
||||||
async function api(url) {
|
async function api(url) {
|
||||||
const response = await fetch(url, { headers: { Accept: 'application/json' } });
|
const response = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||||
|
|
@ -1967,7 +1964,7 @@
|
||||||
}
|
}
|
||||||
const todayProgressView = createTodayProgressView({
|
const todayProgressView = createTodayProgressView({
|
||||||
progress:todayProgress, currentTarget:currentTodayProgressTarget, qs, photos:todayProgressPhotos,
|
progress:todayProgress, currentTarget:currentTodayProgressTarget, qs, photos:todayProgressPhotos,
|
||||||
voice:todayProgressVoice,
|
voice:todayProgressVoice, mentions:todayProgressMentions,
|
||||||
activity:mountTodayProgressActivity(qs,fetchReviewJson,actionHydrator,renderMarkdown),
|
activity:mountTodayProgressActivity(qs,fetchReviewJson,actionHydrator,renderMarkdown),
|
||||||
announce:message => { qs('#my-work-action-status').textContent = message; },
|
announce:message => { qs('#my-work-action-status').textContent = message; },
|
||||||
onAdmitted:() => refreshMyWorkView(),
|
onAdmitted:() => refreshMyWorkView(),
|
||||||
|
|
|
||||||
|
|
@ -1558,6 +1558,8 @@
|
||||||
</section>
|
</section>
|
||||||
<label for="today-progress-body">Update</label>
|
<label for="today-progress-body">Update</label>
|
||||||
<textarea id="today-progress-body" rows="5" maxlength="2000" placeholder="What changed, what you learned, or what comes next"></textarea>
|
<textarea id="today-progress-body" rows="5" maxlength="2000" placeholder="What changed, what you learned, or what comes next"></textarea>
|
||||||
|
<div class="mention-options" id="today-progress-body-mentions" role="listbox" aria-label="Teammates" hidden></div>
|
||||||
|
<div class="mention-status small" id="today-progress-body-mention-status" aria-live="polite"></div>
|
||||||
<section class="voice-conversation" id="voice-today-progress" data-draft-label="progress update" aria-label="Dictate Today progress update" hidden>
|
<section class="voice-conversation" id="voice-today-progress" data-draft-label="progress update" aria-label="Dictate Today progress update" hidden>
|
||||||
<div class="voice-conversation-controls">
|
<div class="voice-conversation-controls">
|
||||||
<button id="start-voice-today-progress" type="button">Dictate update</button>
|
<button id="start-voice-today-progress" type="button">Dictate update</button>
|
||||||
|
|
|
||||||
|
|
@ -247,14 +247,18 @@ function mountTodayProgressActivity(qs, fetchJson, actionSource = {}, render) {
|
||||||
return activity;
|
return activity;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTodayProgressView({ progress, currentTarget, qs, photos, voice, activity, announce = () => {}, onAdmitted = () => {} }) {
|
function createTodayProgressView({ progress, currentTarget, qs, photos, voice, activity, mentions, announce = () => {}, onAdmitted = () => {} }) {
|
||||||
const sheet = qs('#today-progress-sheet');
|
const sheet = qs('#today-progress-sheet');
|
||||||
const body = qs('#today-progress-body');
|
const body = qs('#today-progress-body');
|
||||||
const status = qs('#today-progress-status');
|
const status = qs('#today-progress-status');
|
||||||
const launcher = qs('[data-mobile-today-update]');
|
const launcher = qs('[data-mobile-today-update]');
|
||||||
let openedTarget = null;
|
let openedTarget = null;
|
||||||
|
|
||||||
const update = () => { launcher.hidden = !currentTarget(); };
|
const update = () => {
|
||||||
|
const target = currentTarget();
|
||||||
|
launcher.hidden = !target;
|
||||||
|
if (openedTarget && target?.identity !== openedTarget.identity) mentions?.dismiss?.();
|
||||||
|
};
|
||||||
const checkpoint = async () => {
|
const checkpoint = async () => {
|
||||||
if (!openedTarget) return false;
|
if (!openedTarget) return false;
|
||||||
try {
|
try {
|
||||||
|
|
@ -271,6 +275,7 @@ function createTodayProgressView({ progress, currentTarget, qs, photos, voice, a
|
||||||
const close = () => {
|
const close = () => {
|
||||||
voice?.cancel?.();
|
voice?.cancel?.();
|
||||||
activity?.close?.();
|
activity?.close?.();
|
||||||
|
mentions?.dismiss?.();
|
||||||
if (sheet.open) sheet.close();
|
if (sheet.open) sheet.close();
|
||||||
openedTarget = null;
|
openedTarget = null;
|
||||||
};
|
};
|
||||||
|
|
@ -278,6 +283,7 @@ function createTodayProgressView({ progress, currentTarget, qs, photos, voice, a
|
||||||
launcher.addEventListener('click', async () => {
|
launcher.addEventListener('click', async () => {
|
||||||
const target = currentTarget();
|
const target = currentTarget();
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
|
mentions?.dismiss?.();
|
||||||
openedTarget = target;
|
openedTarget = target;
|
||||||
qs('#today-progress-target').textContent = target.label + (target.title ? ' · ' + target.title : '');
|
qs('#today-progress-target').textContent = target.label + (target.title ? ' · ' + target.title : '');
|
||||||
body.value = progress.load(target.identity);
|
body.value = progress.load(target.identity);
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,11 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||||
self._json(200, REPOSITORY)
|
self._json(200, REPOSITORY)
|
||||||
elif path == "/api/v1/repos/search":
|
elif path == "/api/v1/repos/search":
|
||||||
self._json(200, {"data": [REPOSITORY], "ok": True})
|
self._json(200, {"data": [REPOSITORY], "ok": True})
|
||||||
|
elif path == "/api/v1/repos/acme/mobile/assignees":
|
||||||
|
self._json(200, [
|
||||||
|
USER,
|
||||||
|
{"id": 2, "login": "alex", "full_name": "Alexander", "active": True},
|
||||||
|
])
|
||||||
elif path == "/api/v1/repos/issues/search":
|
elif path == "/api/v1/repos/issues/search":
|
||||||
is_assigned_scan = query.get("assigned") == ["true"] and query.get("type") == ["issues"]
|
is_assigned_scan = query.get("assigned") == ["true"] and query.get("type") == ["issues"]
|
||||||
is_available_scan = not any(
|
is_available_scan = not any(
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,33 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||||
"created_at": "2026-08-18T00:00:00Z",
|
"created_at": "2026-08-18T00:00:00Z",
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
page.locator("[data-mobile-today-update]").click()
|
||||||
|
expect(page.locator("#today-progress-sheet")).to_be_visible()
|
||||||
|
progress = page.locator("#today-progress-body")
|
||||||
|
progress.fill("Pairing with @al")
|
||||||
|
mention = page.locator('#today-progress-body-mentions [data-login="alex"]')
|
||||||
|
try:
|
||||||
|
expect(mention).to_be_visible()
|
||||||
|
except AssertionError as error:
|
||||||
|
raise AssertionError({
|
||||||
|
"status": page.locator("#today-progress-body-mention-status").text_content(),
|
||||||
|
"requests": fake.requests[-20:],
|
||||||
|
"browser_errors": browser_errors,
|
||||||
|
"failed_responses": failed_responses,
|
||||||
|
}) from error
|
||||||
|
mention_bounds = mention.bounding_box()
|
||||||
|
assert mention_bounds and mention_bounds["height"] >= 44
|
||||||
|
mention.click()
|
||||||
|
expect(progress).to_have_value("Pairing with @alex ")
|
||||||
|
page.locator("#post-today-progress").click()
|
||||||
|
expect(page.locator("#today-progress-sheet")).to_be_hidden()
|
||||||
|
admitted = page.evaluate("""() => {
|
||||||
|
const record = JSON.parse(localStorage.getItem('stackchain.authored-outbox.v1') || 'null');
|
||||||
|
return record?.items?.find(item => item.kind === 'issue-comment' && item.number === 41);
|
||||||
|
}""")
|
||||||
|
assert admitted and admitted["repository"] == "acme/mobile"
|
||||||
|
assert admitted["body"] == "Pairing with @alex"
|
||||||
|
|
||||||
page.locator("[data-mobile-today-update]").click()
|
page.locator("[data-mobile-today-update]").click()
|
||||||
expect(page.locator("#today-progress-sheet")).to_be_visible()
|
expect(page.locator("#today-progress-sheet")).to_be_visible()
|
||||||
owned = page.locator('[data-comment-id="701"]')
|
owned = page.locator('[data-comment-id="701"]')
|
||||||
|
|
@ -192,7 +219,10 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||||
expect(page.locator("#issue-comment")).to_have_value("")
|
expect(page.locator("#issue-comment")).to_have_value("")
|
||||||
expect(page.locator("[data-mobile-today-hud]")).to_have_attribute("data-overlay-hidden", "true")
|
expect(page.locator("[data-mobile-today-hud]")).to_have_attribute("data-overlay-hidden", "true")
|
||||||
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Polish desktop filters")
|
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Polish desktop filters")
|
||||||
assert fake.comments == [(41, "Handoff complete; continuing with the next Today item.")]
|
assert fake.comments == [
|
||||||
|
(41, "Pairing with @alex"),
|
||||||
|
(41, "Handoff complete; continuing with the next Today item."),
|
||||||
|
]
|
||||||
|
|
||||||
receipt = page.locator("#today-completion-undo")
|
receipt = page.locator("#today-completion-undo")
|
||||||
expect(receipt).to_be_visible()
|
expect(receipt).to_be_visible()
|
||||||
|
|
|
||||||
|
|
@ -54,12 +54,13 @@ def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||||
css = CSS.read_text()
|
css = CSS.read_text()
|
||||||
|
|
||||||
assert '<script src="static/mention-composer.js"></script>' in html
|
assert '<script src="static/mention-composer.js"></script>' in html
|
||||||
for composer in ("issue-comment", "pull-comment", "update-reply"):
|
for composer in ("issue-comment", "pull-comment", "update-reply", "today-progress-body"):
|
||||||
assert f'id="{composer}-mentions"' in html
|
assert f'id="{composer}-mentions"' in html
|
||||||
assert f'id="{composer}-mention-status"' in html
|
assert f'id="{composer}-mention-status"' in html
|
||||||
assert f"textarea:qs('#{composer}')" in dashboard
|
assert f"['{composer}'," in dashboard
|
||||||
assert f"listbox:qs('#{composer}-mentions')" in dashboard
|
assert dashboard.count("createMentionComposer({") == 1
|
||||||
assert dashboard.count("createMentionComposer({") == 3
|
assert "['today-progress-body',()=>currentTodayProgressTarget()?.repository]" in dashboard
|
||||||
|
assert "mentions:todayProgressMentions" in dashboard
|
||||||
assert "mention-candidates?q=" in dashboard
|
assert "mention-candidates?q=" in dashboard
|
||||||
assert ".mention-options" in css
|
assert ".mention-options" in css
|
||||||
assert "min-height:44px" in css
|
assert "min-height:44px" in css
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,42 @@ const view=createView({{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_sheet_dismisses_mentions_when_opened_switched_or_closed():
|
||||||
|
script = f"""
|
||||||
|
const {{createView}}=require({json.dumps(str(TODAY_PROGRESS))});
|
||||||
|
class Element {{
|
||||||
|
constructor() {{ this.hidden=false;this.value='';this.textContent='';this.open=false;this.listeners={{}}; }}
|
||||||
|
addEventListener(name,callback) {{ this.listeners[name]=callback; }}
|
||||||
|
click() {{ return this.listeners.click?.(); }}
|
||||||
|
showModal() {{ this.open=true; }} close() {{ this.open=false; }} focus() {{}}
|
||||||
|
}}
|
||||||
|
const selectors=['#today-progress-sheet','#today-progress-body','#today-progress-status','[data-mobile-today-update]',
|
||||||
|
'#today-progress-target','#cancel-today-progress','#save-today-progress','#post-today-progress'];
|
||||||
|
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
|
||||||
|
const issue={{identity:'issue:stackchain/dashboard:1064:',kind:'issue',repository:'stackchain/dashboard',number:1064,label:'#1064',title:'Mentions'}};
|
||||||
|
const pull={{identity:'pull:stackchain/dashboard:77:',kind:'pull',repository:'stackchain/dashboard',number:77,label:'#77',title:'Other'}};
|
||||||
|
let target=issue; let dismissals=0;
|
||||||
|
const view=createView({{
|
||||||
|
progress:{{load:()=>'',save:()=>true}},currentTarget:()=>target,qs:selector=>elements[selector],
|
||||||
|
mentions:{{dismiss:()=>dismissals++}},
|
||||||
|
}});
|
||||||
|
(async()=>{{
|
||||||
|
await elements['[data-mobile-today-update]'].click();
|
||||||
|
const afterOpen=dismissals;
|
||||||
|
target=pull; view.update();
|
||||||
|
const afterSwitch=dismissals;
|
||||||
|
await elements['#cancel-today-progress'].click();
|
||||||
|
process.stdout.write(JSON.stringify({{afterOpen,afterSwitch,afterClose:dismissals,closed:!elements['#today-progress-sheet'].open}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
assert run_node(script) == {
|
||||||
|
"afterOpen": 1,
|
||||||
|
"afterSwitch": 2,
|
||||||
|
"afterClose": 3,
|
||||||
|
"closed": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_recent_activity_loads_the_exact_issue_newest_page_before_rendering():
|
def test_recent_activity_loads_the_exact_issue_newest_page_before_rendering():
|
||||||
script = f"""
|
script = f"""
|
||||||
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
|
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user