An Esc during a pointer drag is spent on the drag and leaves the selection. A wheel over the rail re-reads the target each frame, so a drop lands on the row under the pointer. After a bulk removal the focus goes to the next row left, and to the list's menu when the list is emptied. remove-bulk names the videos that went, and its Undo brings back only those. An Undo of a move keeps each row (and when it was saved), leaves out a video that moved on from the target, closes up its place, and says how many came back. Delete with every tick hidden removes nothing and says why. A list gone before the lock answers 404, and a body without its videos 422. One removal path now: the single row goes through the bulk one, the same toast and undo, which also retires the add-at-position route. railDrop reuses the store and scrollParent, the drag image the overlay and the z-index token, and the sensors keep their number. The count is read out from a region that outlives the band. Tests fill the gaps the review named, each seen failing.
111 lines
3.8 KiB
Python
111 lines
3.8 KiB
Python
"""Putting a video back AT a position: the list page's "Undo" of a removal.
|
|
|
|
The row has to come back where it was in ONE request. The two-call shape (append, then send the
|
|
whole order) leaves it at the end of the list whenever the second call fails, and the reorder would
|
|
also overwrite any change another tab made in between. Positions can have gaps after removals, so
|
|
the position is an index over the ORDERED rows, and the list is renumbered around it.
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from app.catalog.models import Channel, Video
|
|
from app.reader.models import Playlist, PlaylistItem
|
|
|
|
|
|
def _seed(db, user, ids, *, positions=None, **kw):
|
|
db.add(Channel(id="UCtest", title="Test"))
|
|
now = datetime.now(timezone.utc)
|
|
for v in [*ids, "vX"]:
|
|
db.add(Video(id=v, channel_id="UCtest", published_at=now))
|
|
pl = Playlist(user_id=user.id, name="L", **kw)
|
|
db.add(pl)
|
|
db.flush()
|
|
for i, vid in enumerate(ids):
|
|
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=(positions or range(99))[i]))
|
|
db.commit()
|
|
return pl
|
|
|
|
|
|
def _ids(db, pl_id):
|
|
db.expire_all()
|
|
return [
|
|
r.video_id
|
|
for r in db.execute(
|
|
PlaylistItem.__table__.select()
|
|
.where(PlaylistItem.playlist_id == pl_id)
|
|
.order_by(PlaylistItem.position)
|
|
).all()
|
|
]
|
|
|
|
|
|
def _restore(client, pl_id, video_id, position):
|
|
return client.post(
|
|
f"/api/playlists/{pl_id}/items/restore",
|
|
json={"video_ids": [video_id], "positions": [position]},
|
|
)
|
|
|
|
|
|
def test_a_position_puts_the_video_back_in_its_place(db, make_user, as_user):
|
|
user = make_user()
|
|
pl = _seed(db, user, ["a", "b", "c"])
|
|
r = _restore(as_user(user), pl.id, "vX", 1)
|
|
assert r.status_code == 200
|
|
assert _ids(db, pl.id) == ["a", "vX", "b", "c"]
|
|
|
|
|
|
def test_the_position_counts_rows_not_stored_numbers(db, make_user, as_user):
|
|
# A removal leaves a gap (0, 5, 9): index 2 is before the THIRD row, not "position 2".
|
|
user = make_user()
|
|
pl = _seed(db, user, ["a", "b", "c"], positions=[0, 5, 9])
|
|
_restore(as_user(user), pl.id, "vX", 2)
|
|
assert _ids(db, pl.id) == ["a", "b", "vX", "c"]
|
|
|
|
|
|
def test_a_position_past_the_end_appends(db, make_user, as_user):
|
|
user = make_user()
|
|
pl = _seed(db, user, ["a", "b"])
|
|
_restore(as_user(user), pl.id, "vX", 40)
|
|
assert _ids(db, pl.id) == ["a", "b", "vX"]
|
|
|
|
|
|
def test_an_add_appends(db, make_user, as_user):
|
|
user = make_user()
|
|
pl = _seed(db, user, ["a", "b"])
|
|
r = as_user(user).post(f"/api/playlists/{pl.id}/items", json={"video_id": "vX"})
|
|
assert r.status_code == 200
|
|
assert _ids(db, pl.id) == ["a", "b", "vX"]
|
|
|
|
|
|
def test_a_video_already_in_the_list_does_not_move(db, make_user, as_user):
|
|
# A restore is idempotent; a position must not turn it into a reorder.
|
|
user = make_user()
|
|
pl = _seed(db, user, ["a", "b", "c"])
|
|
_restore(as_user(user), pl.id, "c", 0)
|
|
assert _ids(db, pl.id) == ["a", "b", "c"]
|
|
|
|
|
|
def test_a_negative_position_is_refused(db, make_user, as_user):
|
|
user = make_user()
|
|
pl = _seed(db, user, ["a"])
|
|
r = _restore(as_user(user), pl.id, "vX", -1)
|
|
assert r.status_code == 422
|
|
assert _ids(db, pl.id) == ["a"]
|
|
|
|
|
|
def test_putting_a_removed_video_back_leaves_a_linked_list_clean(db, make_user, as_user):
|
|
# Remove + undo is no change at all: a YouTube-linked list must not stay "unsynced" after it.
|
|
user = make_user()
|
|
pl = _seed(db, user, ["a", "b", "c"], yt_playlist_id="PLx")
|
|
client = as_user(user)
|
|
from app.sync.playlists import current_fingerprint
|
|
|
|
pl.synced_fingerprint = current_fingerprint(db, pl)
|
|
pl.dirty = False
|
|
db.commit()
|
|
assert client.delete(f"/api/playlists/{pl.id}/items/b").status_code == 200
|
|
db.expire_all()
|
|
assert db.get(Playlist, pl.id).dirty is True
|
|
_restore(client, pl.id, "b", 1)
|
|
db.expire_all()
|
|
assert db.get(Playlist, pl.id).dirty is False
|