- Playlist rows: the X column became the row's "…" (play, pin, copy title/link, move, order,
remove). Remove runs at once, with no confirm; its toast's Undo puts the row back at its index.
The Delete key removes a keyboard-walked row, and focus moves on to the next.
- POST /api/playlists/{id}/items takes an optional `position`, so an undo is one request that
renumbers the list around the row.
- A new `listDelete` key scope. Only a list with `onDelete` registers it, so the help sheet does not
offer Delete where nothing handles it.
- A playlist has one list, `usePlaylistActions.items`: rename, pin, YouTube open/push/revert,
delete. The rail row's "…" and the page header's "…" both open it. Push and revert moved into
the hook; the header keeps Play all.
- Phone-width playlist table: "#" and Channel are hidden below sm (`Column.phoneHidden`). Their
fixed widths had left the title no width.
106 lines
3.8 KiB
Python
106 lines
3.8 KiB
Python
"""E28 S3e2 — adding a video 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.models import Channel, Playlist, PlaylistItem, Video
|
|
|
|
|
|
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 _add(client, pl_id, body):
|
|
return client.post(f"/api/playlists/{pl_id}/items", json=body)
|
|
|
|
|
|
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 = _add(as_user(user), pl.id, {"video_id": "vX", "position": 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])
|
|
_add(as_user(user), pl.id, {"video_id": "vX", "position": 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"])
|
|
_add(as_user(user), pl.id, {"video_id": "vX", "position": 40})
|
|
assert _ids(db, pl.id) == ["a", "b", "vX"]
|
|
|
|
|
|
def test_no_position_still_appends(db, make_user, as_user):
|
|
user = make_user()
|
|
pl = _seed(db, user, ["a", "b"])
|
|
_add(as_user(user), pl.id, {"video_id": "vX"})
|
|
assert _ids(db, pl.id) == ["a", "b", "vX"]
|
|
|
|
|
|
def test_a_video_already_in_the_list_does_not_move(db, make_user, as_user):
|
|
# The add is idempotent; a position must not turn it into a reorder.
|
|
user = make_user()
|
|
pl = _seed(db, user, ["a", "b", "c"])
|
|
_add(as_user(user), pl.id, {"video_id": "c", "position": 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 = _add(as_user(user), pl.id, {"video_id": "vX", "position": -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()
|
|
client.delete(f"/api/playlists/{pl.id}/items/b")
|
|
db.expire_all()
|
|
assert db.get(Playlist, pl.id).dirty is True
|
|
_add(client, pl.id, {"video_id": "b", "position": 1})
|
|
db.expire_all()
|
|
assert db.get(Playlist, pl.id).dirty is False
|