Show saved attachments in editor and after stale edits

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a192e9-c28b-7533-bf25-e2341364c22a
This commit is contained in:
tegwick 2026-10-31 21:50:45 +01:00
parent 1139a6d30e
commit 31eaa581a6
4 changed files with 84 additions and 4 deletions

View file

@ -377,6 +377,14 @@ def page_edit(request: HttpRequest, slug: str, page_slug: str = "index") -> Http
return redirect("spaces:detail", slug=slug)
doc = pageops.read_page(space.slug, page_slug)
# Show files from the saved page even when a rejected edit redisplays the
# submitted fields. Attachment mutations keep their own revision checks.
try:
saved_files = attachments.page_files(space.slug, doc) if doc.exists else []
attachment_error = ""
except (pageops.PageOpsError, OSError) as exc:
saved_files = []
attachment_error = _mutation_error(exc)
if request.method == "POST":
title = (request.POST.get("title") or "").strip()
abstractor = (request.POST.get("abstractor") or "").strip()
@ -421,6 +429,8 @@ def page_edit(request: HttpRequest, slug: str, page_slug: str = "index") -> Http
"submitted_slug": request.POST.get("slug", page_slug),
"is_new": not doc.exists,
"form_title": "Edit or rename page" if doc.exists else "New page",
"saved_files": saved_files,
"attachment_error": attachment_error,
},
)

View file

@ -32,6 +32,30 @@
<h1>{{ form_title }}</h1>
<p class="muted">Change the title to rename the displayed protocol. Change the file name below to rename its Markdown file and page address.</p>
{% if not is_new %}
<section class="card" aria-labelledby="editor-attachments-title">
<h2 id="editor-attachments-title">Attachments</h2>
<p class="muted">Files on the saved page. Editing or renaming the page keeps its attachments.</p>
{% if attachment_error %}
<p>{{ attachment_error }}</p>
{% else %}
<ul>
{% for file in saved_files %}
<li>
<strong>{{ file.name }}</strong>
{% if file.open_url %}<a href="{{ file.open_url }}" target="_blank" rel="noopener">Open image</a>{% endif %}
<a href="{{ file.download_url }}">Download</a>
</li>
{% empty %}
<li>No attachments on this page.</li>
{% endfor %}
</ul>
{% endif %}
<a href="{% url 'spaces:page_attachments' space.slug doc.slug %}" target="_blank" rel="noopener">Manage attachments (new tab)</a>
<p class="muted">Save your edits before adding or removing files, then reopen the editor.</p>
</section>
{% endif %}
<form method="post" class="card" style="margin-top:1rem;">
{% csrf_token %}
<input type="hidden" name="revision" value="{{ doc.revision }}">

View file

@ -32,9 +32,9 @@ try {
? 'Open, download or manage attachments' : 'Open or download attachments', exact: true}).click();
await page.waitForURL('**/attachments/');
}
async function downloadedBytes() {
const pending = page.waitForEvent('download');
await page.getByRole('link', {name: 'Download', exact: true}).click();
async function downloadedBytes(tab = page) {
const pending = tab.waitForEvent('download');
await tab.getByRole('link', {name: 'Download', exact: true}).click();
const stream = await (await pending).createReadStream();
const chunks = [];
for await (const chunk of stream) chunks.push(chunk);
@ -50,6 +50,35 @@ try {
assert.match(await page.locator('main').innerText(), /Attachment added/);
const originalDownload = await page.getByRole('link', {name: 'Download', exact: true}).getAttribute('href');
assert.equal(await downloadedBytes(), 'Synthetic attachment bytes');
const editUrl = `${shared}pages/notes/edit/`;
await page.goto(editUrl);
const files = page.getByRole('region', {name: 'Attachments', exact: true});
assert.match(await files.innerText(), /meeting-notes.txt/);
assert.equal(await downloadedBytes(), 'Synthetic attachment bytes');
await page.getByLabel('Body (markdown)').fill('Unsaved author notes');
const opened = context.waitForEvent('page');
await page.getByRole('link', {name: 'Manage attachments (new tab)', exact: true}).click();
const management = await opened;
await management.waitForURL('**/attachments/');
assert.equal(await page.getByLabel('Body (markdown)').inputValue(), 'Unsaved author notes');
assert.match(await management.locator('main').innerText(), /meeting-notes.txt/);
await management.close();
const stale = await context.newPage();
await stale.goto(editUrl);
assert.match(await stale.getByRole('region', {name: 'Attachments', exact: true}).innerText(), /meeting-notes.txt/);
await stale.getByLabel('Body (markdown)').fill('Keep my stale edit');
await page.getByRole('button', {name: 'Save page', exact: true}).click();
await page.waitForURL('**?page=notes');
await stale.getByRole('button', {name: 'Save page', exact: true}).click();
await stale.waitForURL('**/edit/');
assert.match(await stale.locator('main').innerText(), /This page changed/);
assert.equal(await stale.getByLabel('Body (markdown)').inputValue(), 'Keep my stale edit');
assert.match(await stale.getByRole('region', {name: 'Attachments', exact: true}).innerText(), /meeting-notes.txt/);
assert.equal(await downloadedBytes(stale), 'Synthetic attachment bytes');
await stale.close();
await page.goto(editUrl);
assert.equal(await page.getByLabel('Body (markdown)').inputValue(), 'Unsaved author notes\n');
assert.match(await files.innerText(), /meeting-notes.txt/);
await login('file-reader');
await menu(source, false);
assert.equal(await page.getByRole('button', {name: 'Add attachment', exact: true}).count(), 0);

View file

@ -151,7 +151,24 @@ def test_inline_body_files_have_authenticated_download_links(people, client):
def test_edit_and_rename_keep_attachment_associations(people, client):
space, doc, _, _, _ = people
added = upload(space, doc)
response = client.post(reverse("spaces:page_edit", args=[space.slug, doc.slug]), {
edit_url = reverse("spaces:page_edit", args=[space.slug, doc.slug])
response = client.get(edit_url)
assert response.status_code == 200
assert len(response.context["saved_files"]) == 1
download = response.context["saved_files"][0]["download_url"]
assert download.encode() in response.content
# Another tab changes the page after this editor opened. Redisplaying the
# rejected text must still expose the saved attachments without losing them.
stale = client.post(edit_url, {
"title": "Older edit", "slug": doc.slug, "body": "Unsaved text",
"revision": doc.revision,
})
assert stale.status_code == 200
assert b"This page changed" in stale.content
assert stale.context["doc"].body == "Unsaved text"
assert download.encode() in stale.content
assert pageops.read_page(space.slug, doc.slug).revision == added.revision
response = client.post(edit_url, {
"title": "Renamed notes", "slug": "renamed", "body": "Updated notes",
"revision": added.revision,
})