fix(playlists): what the first review of bulk actions found
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.
This commit is contained in:
@@ -406,30 +406,6 @@ def remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def place_item(db: Session, pl: Playlist, video_id: str, position: int) -> None:
|
|
||||||
"""Put a video already in the list at `position` (clamped to the end), renumbering the whole
|
|
||||||
list: positions can have gaps after removals, so "index N" is only meaningful over the
|
|
||||||
ordered rows."""
|
|
||||||
rows = queries.items(db, pl.id)
|
|
||||||
new = next(r for r in rows if r.video_id == video_id)
|
|
||||||
rows.remove(new)
|
|
||||||
rows.insert(min(position, len(rows)), new)
|
|
||||||
for pos, row in enumerate(rows):
|
|
||||||
row.position = pos
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def add_to_playlist(db: Session, pl: Playlist, video_id: str, position: int | None) -> None:
|
|
||||||
"""Add a video to one of the user's lists, at `position` when given (the list page's "Undo" of
|
|
||||||
a removal puts the row back where it was in ONE request), else at the end. A video already in
|
|
||||||
the list stays where it is."""
|
|
||||||
added = add_item(db, pl, video_id, mark_dirty=False)
|
|
||||||
if added and position is not None:
|
|
||||||
place_item(db, pl, video_id, position)
|
|
||||||
recompute_dirty(db, pl)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def rename_playlist(db: Session, pl: Playlist, name: str) -> None:
|
def rename_playlist(db: Session, pl: Playlist, name: str) -> None:
|
||||||
"""A new name moves `dirty`; the caller holds the playlist's lock (the scheduled mirror refresh
|
"""A new name moves `dirty`; the caller holds the playlist's lock (the scheduled mirror refresh
|
||||||
reads `dirty` under it)."""
|
reads `dirty` under it)."""
|
||||||
@@ -447,9 +423,9 @@ def delete_playlist(db: Session, pl: Playlist) -> None:
|
|||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
def remove_items(db: Session, pl: Playlist, video_ids: list[str]) -> int:
|
def remove_items(db: Session, pl: Playlist, video_ids: list[str]) -> list[str]:
|
||||||
"""Remove these videos from a playlist, in one transaction; a video not in it any more is simply
|
"""Remove these videos from a playlist, in one transaction; a video not in it any more is simply
|
||||||
not counted. Says how many went."""
|
not among them. Says which went: an Undo must not bring back one this removal never took."""
|
||||||
lock_playlists(db, pl.id)
|
lock_playlists(db, pl.id)
|
||||||
wanted = set(video_ids)
|
wanted = set(video_ids)
|
||||||
rows = [it for it in queries.items(db, pl.id) if it.video_id in wanted]
|
rows = [it for it in queries.items(db, pl.id) if it.video_id in wanted]
|
||||||
@@ -459,24 +435,38 @@ def remove_items(db: Session, pl: Playlist, video_ids: list[str]) -> int:
|
|||||||
recompute_dirty(db, pl)
|
recompute_dirty(db, pl)
|
||||||
touch(pl)
|
touch(pl)
|
||||||
db.flush()
|
db.flush()
|
||||||
return len(rows)
|
return [row.video_id for row in rows]
|
||||||
|
|
||||||
|
|
||||||
def _insert_at(db: Session, pl: Playlist, placed: list[tuple[int, str]]) -> int:
|
def _insert_at(
|
||||||
|
db: Session,
|
||||||
|
pl: Playlist,
|
||||||
|
placed: list[tuple[int, str]],
|
||||||
|
leaving: dict[str, PlaylistItem] | None = None,
|
||||||
|
staying_out: frozenset[str] = frozenset(),
|
||||||
|
) -> int:
|
||||||
"""Add videos at their indices (ascending, so each index is where the row stood when the rows
|
"""Add videos at their indices (ascending, so each index is where the row stood when the rows
|
||||||
before it were back) and renumber the list 0..n-1, as positions can have gaps. A video already in
|
before it were back) and renumber the list 0..n-1, as positions can have gaps. A video already in
|
||||||
the list stays where it is, and one the catalog has lost is left out. Says how many were added;
|
the list stays where it is, and one the catalog has lost or in `staying_out` is left out, the
|
||||||
the caller holds the playlist's lock."""
|
ones after it closing up. A video with a row in `leaving` brings that row along, for the reason
|
||||||
|
`move_items` re-parents. Says how many were added; the caller holds the playlist's lock."""
|
||||||
|
leaving = leaving or {}
|
||||||
rows = queries.items(db, pl.id)
|
rows = queries.items(db, pl.id)
|
||||||
present = {r.video_id for r in rows}
|
present = {r.video_id for r in rows}
|
||||||
known = queries.known_video_ids(db, [vid for _, vid in placed])
|
known = queries.known_video_ids(db, [vid for _, vid in placed])
|
||||||
added = 0
|
added = 0
|
||||||
|
left_out = 0
|
||||||
for at, vid in sorted(placed, key=lambda p: p[0]):
|
for at, vid in sorted(placed, key=lambda p: p[0]):
|
||||||
if vid in present or vid not in known:
|
if vid in present or vid not in known or vid in staying_out:
|
||||||
|
left_out += 1
|
||||||
continue
|
continue
|
||||||
row = PlaylistItem(playlist_id=pl.id, video_id=vid, position=0)
|
row = leaving.pop(vid, None)
|
||||||
db.add(row)
|
if row is None:
|
||||||
rows.insert(min(at, len(rows)), row)
|
row = PlaylistItem(playlist_id=pl.id, video_id=vid, position=0)
|
||||||
|
db.add(row)
|
||||||
|
else:
|
||||||
|
row.playlist_id = pl.id
|
||||||
|
rows.insert(min(at - left_out, len(rows)), row)
|
||||||
present.add(vid)
|
present.add(vid)
|
||||||
added += 1
|
added += 1
|
||||||
for pos, row in enumerate(rows):
|
for pos, row in enumerate(rows):
|
||||||
@@ -496,6 +486,24 @@ def restore_items(db: Session, pl: Playlist, video_ids: list[str], positions: li
|
|||||||
return added
|
return added
|
||||||
|
|
||||||
|
|
||||||
|
class ListGone(Exception):
|
||||||
|
"""A list of a two-list write was deleted between the route's read and the lock."""
|
||||||
|
|
||||||
|
def __init__(self, *, source: bool):
|
||||||
|
super().__init__("source" if source else "target")
|
||||||
|
self.source = source
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_both(db: Session, src: Playlist, dst: Playlist) -> None:
|
||||||
|
"""Both lists, in id order: a move A→B beside a move B→A would otherwise lock crosswise. A list
|
||||||
|
already gone is said here: a write into it would answer with its foreign key's 500."""
|
||||||
|
found = lock_playlists(db, src.id, dst.id)
|
||||||
|
if src.id not in found:
|
||||||
|
raise ListGone(source=True)
|
||||||
|
if dst.id not in found:
|
||||||
|
raise ListGone(source=False)
|
||||||
|
|
||||||
|
|
||||||
def move_items(
|
def move_items(
|
||||||
db: Session, src: Playlist, dst: Playlist, video_ids: list[str]
|
db: Session, src: Playlist, dst: Playlist, video_ids: list[str]
|
||||||
) -> tuple[list[str], list[str]]:
|
) -> tuple[list[str], list[str]]:
|
||||||
@@ -506,8 +514,7 @@ def move_items(
|
|||||||
list twice."""
|
list twice."""
|
||||||
if src.id == dst.id:
|
if src.id == dst.id:
|
||||||
raise MoveRefused("same_playlist")
|
raise MoveRefused("same_playlist")
|
||||||
# Both lists, in id order: a move A→B beside a move B→A would otherwise lock crosswise.
|
_lock_both(db, src, dst)
|
||||||
lock_playlists(db, src.id, dst.id)
|
|
||||||
wanted = set(video_ids)
|
wanted = set(video_ids)
|
||||||
rows = [it for it in queries.items(db, src.id) if it.video_id in wanted]
|
rows = [it for it in queries.items(db, src.id) if it.video_id in wanted]
|
||||||
held = queries.in_playlist(db, dst.id, [r.video_id for r in rows])
|
held = queries.in_playlist(db, dst.id, [r.video_id for r in rows])
|
||||||
@@ -547,17 +554,23 @@ def undo_move(
|
|||||||
already_there: list[str],
|
already_there: list[str],
|
||||||
positions: list[int],
|
positions: list[int],
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Take a move back in one transaction: the `moved` videos leave `dst` and every video returns to
|
"""Take a move back in one transaction: the `moved` videos leave `dst` and return to their places
|
||||||
its place in `src`, while the `already_there` ones stay in `dst`, which held them before.
|
in `src`, keeping their rows, as the move did. The `already_there` ones return too and stay in
|
||||||
`positions` pairs with `moved` + `already_there`. Says how many came back."""
|
`dst`, which held them before. A moved video `dst` no longer holds has gone on from there, and
|
||||||
lock_playlists(db, src.id, dst.id)
|
bringing it back would leave it in lists the reader never put it in, so it stays out, as a
|
||||||
|
move skips a video a concurrent edit took out. `positions` pairs with `moved` + `already_there`.
|
||||||
|
Says how many came back."""
|
||||||
|
_lock_both(db, src, dst)
|
||||||
wanted = set(moved)
|
wanted = set(moved)
|
||||||
rows = [it for it in queries.items(db, dst.id) if it.video_id in wanted]
|
rows = {it.video_id: it for it in queries.items(db, dst.id) if it.video_id in wanted}
|
||||||
for row in rows:
|
changed = bool(rows)
|
||||||
|
placed = list(zip(positions, [*moved, *already_there], strict=True))
|
||||||
|
back = _insert_at(db, src, placed, leaving=rows, staying_out=frozenset(wanted - rows.keys()))
|
||||||
|
# What `src` holds again by now only leaves `dst`.
|
||||||
|
for row in rows.values():
|
||||||
db.delete(row)
|
db.delete(row)
|
||||||
db.flush()
|
db.flush()
|
||||||
back = _insert_at(db, src, list(zip(positions, [*moved, *already_there], strict=True)))
|
if changed:
|
||||||
if rows:
|
|
||||||
recompute_dirty(db, dst)
|
recompute_dirty(db, dst)
|
||||||
touch(dst)
|
touch(dst)
|
||||||
if back:
|
if back:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope
|
|||||||
from typing import TYPE_CHECKING, Literal
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, model_validator
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import quota
|
from app import quota
|
||||||
@@ -386,24 +386,17 @@ def delete_playlist(
|
|||||||
return {"deleted": playlist_id}
|
return {"deleted": playlist_id}
|
||||||
|
|
||||||
|
|
||||||
class AddItemIn(VideoRefIn):
|
|
||||||
# Where the video lands among the list's items, in their stored order (0 = first). Omitted, it
|
|
||||||
# is appended. The list page's "Undo" of a removal puts the row back where it was with this —
|
|
||||||
# ONE request, because append-then-reorder leaves it at the end whenever the second call fails.
|
|
||||||
position: int | None = Field(default=None, ge=0)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{playlist_id}/items")
|
@router.post("/{playlist_id}/items")
|
||||||
def add_item(
|
def add_item(
|
||||||
playlist_id: int,
|
playlist_id: int,
|
||||||
body: AddItemIn,
|
body: VideoRefIn,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = _own_playlist(db, user, playlist_id)
|
pl = _own_playlist(db, user, playlist_id, lock=True)
|
||||||
video_id = body.video_id
|
video_id = body.video_id
|
||||||
_video_or_404(db, video_id)
|
_video_or_404(db, video_id)
|
||||||
service.add_to_playlist(db, pl, video_id, body.position)
|
service.add_item(db, pl, video_id, mark_dirty=True)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
|
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
|
||||||
|
|
||||||
@@ -415,14 +408,15 @@ def remove_item(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = _own_playlist(db, user, playlist_id)
|
pl = _own_playlist(db, user, playlist_id, lock=True)
|
||||||
service.remove_item(db, pl, video_id, mark_dirty=True)
|
service.remove_item(db, pl, video_id, mark_dirty=True)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
|
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
|
||||||
|
|
||||||
|
|
||||||
class VideoIdsIn(BaseModel):
|
class VideoIdsIn(BaseModel):
|
||||||
video_ids: list[str] = []
|
# Required: with a default, a misnamed key answered 200 having acted on no video.
|
||||||
|
video_ids: list[str]
|
||||||
|
|
||||||
|
|
||||||
def _paired(video_count: int, positions: list[int]) -> None:
|
def _paired(video_count: int, positions: list[int]) -> None:
|
||||||
@@ -460,6 +454,11 @@ class UndoMoveIn(BaseModel):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def _list_gone(e: service.ListGone) -> HTTPException:
|
||||||
|
"""Each list of a two-list write under the reason the route's own read gives it."""
|
||||||
|
return not_found("playlist") if e.source else not_found("move_target")
|
||||||
|
|
||||||
|
|
||||||
def _other_list(db: Session, user: User, playlist_id: int) -> "Playlist":
|
def _other_list(db: Session, user: User, playlist_id: int) -> "Playlist":
|
||||||
"""The other list of a move. Its own reason: "this playlist doesn't exist any more" about a list
|
"""The other list of a move. Its own reason: "this playlist doesn't exist any more" about a list
|
||||||
that is gone was read as the one on screen, which is still there."""
|
that is gone was read as the one on screen, which is still there."""
|
||||||
@@ -476,8 +475,8 @@ def remove_items(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Remove many videos from a playlist at once. Returns how many went."""
|
"""Remove many videos from a playlist at once. Returns the ones that went."""
|
||||||
pl = _own_playlist(db, user, playlist_id)
|
pl = _own_playlist(db, user, playlist_id, lock=True)
|
||||||
removed = service.remove_items(db, pl, body.video_ids)
|
removed = service.remove_items(db, pl, body.video_ids)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "playlist_id": pl.id, "removed": removed}
|
return {"ok": True, "playlist_id": pl.id, "removed": removed}
|
||||||
@@ -491,7 +490,7 @@ def restore_items(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""The "Undo" of a removal: the videos back at their places. Returns how many came back."""
|
"""The "Undo" of a removal: the videos back at their places. Returns how many came back."""
|
||||||
pl = _own_playlist(db, user, playlist_id)
|
pl = _own_playlist(db, user, playlist_id, lock=True)
|
||||||
added = service.restore_items(db, pl, body.video_ids, body.positions)
|
added = service.restore_items(db, pl, body.video_ids, body.positions)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "playlist_id": pl.id, "added": added}
|
return {"ok": True, "playlist_id": pl.id, "added": added}
|
||||||
@@ -519,6 +518,8 @@ def move_items(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail={"code": "playlist_move", "reason": e.reason}
|
status_code=400, detail={"code": "playlist_move", "reason": e.reason}
|
||||||
) from None
|
) from None
|
||||||
|
except service.ListGone as e:
|
||||||
|
raise _list_gone(e) from None
|
||||||
db.commit()
|
db.commit()
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
@@ -539,19 +540,18 @@ def undo_move(
|
|||||||
"""The "Undo" of a move, back into this playlist in one transaction. Returns how many came back."""
|
"""The "Undo" of a move, back into this playlist in one transaction. Returns how many came back."""
|
||||||
src = _own_playlist(db, user, playlist_id)
|
src = _own_playlist(db, user, playlist_id)
|
||||||
dst = _other_list(db, user, body.from_playlist_id)
|
dst = _other_list(db, user, body.from_playlist_id)
|
||||||
added = service.undo_move(db, src, dst, body.moved, body.already_there, body.positions)
|
try:
|
||||||
|
added = service.undo_move(db, src, dst, body.moved, body.already_there, body.positions)
|
||||||
|
except service.ListGone as e:
|
||||||
|
raise _list_gone(e) from None
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "playlist_id": src.id, "added": added}
|
return {"ok": True, "playlist_id": src.id, "added": added}
|
||||||
|
|
||||||
|
|
||||||
class ReorderItemsIn(BaseModel):
|
|
||||||
video_ids: list[str] = []
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{playlist_id}/order")
|
@router.put("/{playlist_id}/order")
|
||||||
def reorder_items(
|
def reorder_items(
|
||||||
playlist_id: int,
|
playlist_id: int,
|
||||||
body: ReorderItemsIn,
|
body: VideoIdsIn,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Adding a video AT a position: the list page's "Undo" of a removal.
|
"""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
|
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
|
whole order) leaves it at the end of the list whenever the second call fails, and the reorder would
|
||||||
@@ -38,14 +38,17 @@ def _ids(db, pl_id):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _add(client, pl_id, body):
|
def _restore(client, pl_id, video_id, position):
|
||||||
return client.post(f"/api/playlists/{pl_id}/items", json=body)
|
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):
|
def test_a_position_puts_the_video_back_in_its_place(db, make_user, as_user):
|
||||||
user = make_user()
|
user = make_user()
|
||||||
pl = _seed(db, user, ["a", "b", "c"])
|
pl = _seed(db, user, ["a", "b", "c"])
|
||||||
r = _add(as_user(user), pl.id, {"video_id": "vX", "position": 1})
|
r = _restore(as_user(user), pl.id, "vX", 1)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert _ids(db, pl.id) == ["a", "vX", "b", "c"]
|
assert _ids(db, pl.id) == ["a", "vX", "b", "c"]
|
||||||
|
|
||||||
@@ -54,36 +57,37 @@ 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".
|
# A removal leaves a gap (0, 5, 9): index 2 is before the THIRD row, not "position 2".
|
||||||
user = make_user()
|
user = make_user()
|
||||||
pl = _seed(db, user, ["a", "b", "c"], positions=[0, 5, 9])
|
pl = _seed(db, user, ["a", "b", "c"], positions=[0, 5, 9])
|
||||||
_add(as_user(user), pl.id, {"video_id": "vX", "position": 2})
|
_restore(as_user(user), pl.id, "vX", 2)
|
||||||
assert _ids(db, pl.id) == ["a", "b", "vX", "c"]
|
assert _ids(db, pl.id) == ["a", "b", "vX", "c"]
|
||||||
|
|
||||||
|
|
||||||
def test_a_position_past_the_end_appends(db, make_user, as_user):
|
def test_a_position_past_the_end_appends(db, make_user, as_user):
|
||||||
user = make_user()
|
user = make_user()
|
||||||
pl = _seed(db, user, ["a", "b"])
|
pl = _seed(db, user, ["a", "b"])
|
||||||
_add(as_user(user), pl.id, {"video_id": "vX", "position": 40})
|
_restore(as_user(user), pl.id, "vX", 40)
|
||||||
assert _ids(db, pl.id) == ["a", "b", "vX"]
|
assert _ids(db, pl.id) == ["a", "b", "vX"]
|
||||||
|
|
||||||
|
|
||||||
def test_no_position_still_appends(db, make_user, as_user):
|
def test_an_add_appends(db, make_user, as_user):
|
||||||
user = make_user()
|
user = make_user()
|
||||||
pl = _seed(db, user, ["a", "b"])
|
pl = _seed(db, user, ["a", "b"])
|
||||||
_add(as_user(user), pl.id, {"video_id": "vX"})
|
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"]
|
assert _ids(db, pl.id) == ["a", "b", "vX"]
|
||||||
|
|
||||||
|
|
||||||
def test_a_video_already_in_the_list_does_not_move(db, make_user, as_user):
|
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.
|
# A restore is idempotent; a position must not turn it into a reorder.
|
||||||
user = make_user()
|
user = make_user()
|
||||||
pl = _seed(db, user, ["a", "b", "c"])
|
pl = _seed(db, user, ["a", "b", "c"])
|
||||||
_add(as_user(user), pl.id, {"video_id": "c", "position": 0})
|
_restore(as_user(user), pl.id, "c", 0)
|
||||||
assert _ids(db, pl.id) == ["a", "b", "c"]
|
assert _ids(db, pl.id) == ["a", "b", "c"]
|
||||||
|
|
||||||
|
|
||||||
def test_a_negative_position_is_refused(db, make_user, as_user):
|
def test_a_negative_position_is_refused(db, make_user, as_user):
|
||||||
user = make_user()
|
user = make_user()
|
||||||
pl = _seed(db, user, ["a"])
|
pl = _seed(db, user, ["a"])
|
||||||
r = _add(as_user(user), pl.id, {"video_id": "vX", "position": -1})
|
r = _restore(as_user(user), pl.id, "vX", -1)
|
||||||
assert r.status_code == 422
|
assert r.status_code == 422
|
||||||
assert _ids(db, pl.id) == ["a"]
|
assert _ids(db, pl.id) == ["a"]
|
||||||
|
|
||||||
@@ -101,6 +105,6 @@ def test_putting_a_removed_video_back_leaves_a_linked_list_clean(db, make_user,
|
|||||||
assert client.delete(f"/api/playlists/{pl.id}/items/b").status_code == 200
|
assert client.delete(f"/api/playlists/{pl.id}/items/b").status_code == 200
|
||||||
db.expire_all()
|
db.expire_all()
|
||||||
assert db.get(Playlist, pl.id).dirty is True
|
assert db.get(Playlist, pl.id).dirty is True
|
||||||
_add(client, pl.id, {"video_id": "b", "position": 1})
|
_restore(client, pl.id, "b", 1)
|
||||||
db.expire_all()
|
db.expire_all()
|
||||||
assert db.get(Playlist, pl.id).dirty is False
|
assert db.get(Playlist, pl.id).dirty is False
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import pytest
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.catalog.models import Channel, Video
|
from app.catalog.models import Channel, Video
|
||||||
from app.reader import service
|
from app.reader import queries, service
|
||||||
from app.reader.models import Playlist, PlaylistItem
|
from app.reader.models import Playlist, PlaylistItem
|
||||||
|
from app.sync.playlists import current_fingerprint
|
||||||
|
|
||||||
|
|
||||||
def _seed_videos(db, ids):
|
def _seed_videos(db, ids):
|
||||||
@@ -61,7 +62,7 @@ def test_remove_bulk_takes_out_the_named_videos_and_counts_what_was_there(db, ma
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json() == {"ok": True, "playlist_id": pl.id, "removed": 2}
|
assert r.json() == {"ok": True, "playlist_id": pl.id, "removed": ["b", "d"]}
|
||||||
assert _ids(db, pl.id) == ["a", "c"]
|
assert _ids(db, pl.id) == ["a", "c"]
|
||||||
|
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ def test_remove_bulk_of_nothing_leaves_the_list_untouched(db, make_user, as_user
|
|||||||
f"/api/playlists/{pl.id}/items/remove-bulk", json={"video_ids": ["gone"]}
|
f"/api/playlists/{pl.id}/items/remove-bulk", json={"video_ids": ["gone"]}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert r.json()["removed"] == 0
|
assert r.json()["removed"] == []
|
||||||
db.expire_all()
|
db.expire_all()
|
||||||
assert db.get(Playlist, pl.id).updated_at == stamp
|
assert db.get(Playlist, pl.id).updated_at == stamp
|
||||||
|
|
||||||
@@ -276,6 +277,238 @@ def test_undo_move_restores_both_lists_as_they_stood(db, make_user, as_user):
|
|||||||
assert _ids(db, dst.id) == ["c"]
|
assert _ids(db, dst.id) == ["c"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_undo_move_keeps_when_each_video_was_saved(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a"])
|
||||||
|
src = _playlist(db, user, "Source", [])
|
||||||
|
dst = _playlist(db, user, "Target", ["a"])
|
||||||
|
saved = datetime(2020, 1, 2, tzinfo=timezone.utc)
|
||||||
|
row = db.scalar(select(PlaylistItem).where(PlaylistItem.playlist_id == dst.id))
|
||||||
|
row.added_at = saved
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{src.id}/items/undo-move",
|
||||||
|
json={"from_playlist_id": dst.id, "moved": ["a"], "positions": [0]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.json()["added"] == 1
|
||||||
|
db.expire_all()
|
||||||
|
back = db.scalar(select(PlaylistItem).where(PlaylistItem.playlist_id == src.id))
|
||||||
|
assert back.added_at == saved
|
||||||
|
|
||||||
|
|
||||||
|
def test_undo_move_leaves_a_video_that_moved_on_where_it_is(db, make_user, as_user):
|
||||||
|
"""A to B, then B to C, then the first Undo: the video is not in the list the Undo takes it from,
|
||||||
|
and coming back would leave it in two lists the reader never put it in."""
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b"])
|
||||||
|
src = _playlist(db, user, "Source", [])
|
||||||
|
dst = _playlist(db, user, "Target", ["b"])
|
||||||
|
third = _playlist(db, user, "Third", ["a"])
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{src.id}/items/undo-move",
|
||||||
|
json={"from_playlist_id": dst.id, "moved": ["a", "b"], "positions": [0, 1]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.json()["added"] == 1
|
||||||
|
assert _ids(db, src.id) == ["b"]
|
||||||
|
assert _ids(db, dst.id) == []
|
||||||
|
assert _ids(db, third.id) == ["a"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_undo_closes_up_the_places_of_a_video_that_stayed_out(db, make_user, as_user):
|
||||||
|
"""Each index counts the rows put back before it, so one that stays out moves the rest up."""
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b", "c", "d"])
|
||||||
|
src = _playlist(db, user, "Source", ["c", "d"])
|
||||||
|
dst = _playlist(db, user, "Target", ["b"])
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{src.id}/items/undo-move",
|
||||||
|
json={"from_playlist_id": dst.id, "moved": ["a", "b"], "positions": [0, 1]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.json()["added"] == 1
|
||||||
|
assert _ids(db, src.id) == ["b", "c", "d"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_restore_closes_up_the_places_of_a_video_the_catalog_lost(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["b", "c", "d"])
|
||||||
|
pl = _playlist(db, user, "L", ["c", "d"])
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{pl.id}/items/restore",
|
||||||
|
json={"video_ids": ["lost", "b"], "positions": [0, 1]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.json()["added"] == 1
|
||||||
|
assert _ids(db, pl.id) == ["b", "c", "d"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_undo_move_takes_a_video_out_of_the_target_when_the_source_has_it_again(
|
||||||
|
db, make_user, as_user
|
||||||
|
):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a"])
|
||||||
|
src = _playlist(db, user, "Source", ["a"])
|
||||||
|
dst = _playlist(db, user, "Target", ["a"])
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{src.id}/items/undo-move",
|
||||||
|
json={"from_playlist_id": dst.id, "moved": ["a"], "positions": [0]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.json()["added"] == 0
|
||||||
|
assert _ids(db, src.id) == ["a"]
|
||||||
|
assert _ids(db, dst.id) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("path", "body", "gone", "reason"),
|
||||||
|
[
|
||||||
|
("move-bulk", {"video_ids": ["a"], "target_playlist_id": "dst"}, "dst", "move_target"),
|
||||||
|
("move-bulk", {"video_ids": ["a"], "target_playlist_id": "dst"}, "src", "playlist"),
|
||||||
|
(
|
||||||
|
"undo-move",
|
||||||
|
{"from_playlist_id": "dst", "moved": ["a"], "positions": [0]},
|
||||||
|
"src",
|
||||||
|
"playlist",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"undo-move",
|
||||||
|
{"from_playlist_id": "dst", "moved": ["a"], "positions": [0]},
|
||||||
|
"dst",
|
||||||
|
"move_target",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_list_deleted_before_the_lock_is_not_found(
|
||||||
|
db, make_user, as_user, monkeypatch, path, body, gone, reason
|
||||||
|
):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a"])
|
||||||
|
src = _playlist(db, user, "Source", ["a"])
|
||||||
|
dst = _playlist(db, user, "Target", ["a"])
|
||||||
|
ids = {"src": src.id, "dst": dst.id}
|
||||||
|
body = {k: (dst.id if v == "dst" else v) for k, v in body.items()}
|
||||||
|
# Another tab's delete committed between the route's read and this lock.
|
||||||
|
monkeypatch.setattr(service, "lock_playlists", lambda _db, *got: set(got) - {ids[gone]})
|
||||||
|
|
||||||
|
r = as_user(user).post(f"/api/playlists/{src.id}/items/{path}", json=body)
|
||||||
|
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert reason in str(r.json()["detail"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("method", "path", "body"),
|
||||||
|
[
|
||||||
|
("post", "items", {"video_id": "a"}),
|
||||||
|
("delete", "items/a", None),
|
||||||
|
("post", "items/remove-bulk", {"video_ids": ["a"]}),
|
||||||
|
("post", "items/restore", {"video_ids": ["a"], "positions": [0]}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_one_list_write_finds_its_list_under_the_lock(
|
||||||
|
db, make_user, as_user, monkeypatch, method, path, body
|
||||||
|
):
|
||||||
|
"""Read with its lock, so a delete that won the row answers 404 rather than a foreign key's 500."""
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a"])
|
||||||
|
pl = _playlist(db, user, "L", [])
|
||||||
|
real = queries.own_playlist
|
||||||
|
|
||||||
|
def locked_read_finds_nothing(db_, user_, playlist_id, *, lock=False):
|
||||||
|
return None if lock else real(db_, user_, playlist_id)
|
||||||
|
|
||||||
|
monkeypatch.setattr(queries, "own_playlist", locked_read_finds_nothing)
|
||||||
|
kw = {"json": body} if body is not None else {}
|
||||||
|
|
||||||
|
r = getattr(as_user(user), method)(f"/api/playlists/{pl.id}/{path}", **kw)
|
||||||
|
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("path", "body"),
|
||||||
|
[
|
||||||
|
("items/remove-bulk", {"ids": ["a"]}),
|
||||||
|
("items/move-bulk", {"ids": ["a"], "target_playlist_id": 1}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_body_without_its_videos_is_refused_rather_than_acting_on_none(
|
||||||
|
db, make_user, as_user, path, body
|
||||||
|
):
|
||||||
|
user = make_user()
|
||||||
|
pl = _playlist(db, user, "L", [])
|
||||||
|
|
||||||
|
assert as_user(user).post(f"/api/playlists/{pl.id}/{path}", json=body).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_reorder_without_its_videos_is_refused(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
pl = _playlist(db, user, "L", [])
|
||||||
|
|
||||||
|
assert as_user(user).put(f"/api/playlists/{pl.id}/order", json={"ids": []}).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def _synced(db, pl):
|
||||||
|
pl.synced_fingerprint = current_fingerprint(db, pl)
|
||||||
|
pl.dirty = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bulk_remove_and_its_undo_leave_a_linked_list_as_it_was(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b"])
|
||||||
|
pl = _playlist(db, user, "L", ["a", "b"], yt_playlist_id="PLx")
|
||||||
|
_synced(db, pl)
|
||||||
|
client = as_user(user)
|
||||||
|
|
||||||
|
r = client.post(f"/api/playlists/{pl.id}/items/remove-bulk", json={"video_ids": ["a"]})
|
||||||
|
assert r.status_code == 200
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Playlist, pl.id).dirty is True
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
f"/api/playlists/{pl.id}/items/restore", json={"video_ids": ["a"], "positions": [0]}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Playlist, pl.id).dirty is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_move_and_its_undo_leave_both_linked_lists_as_they_were(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b"])
|
||||||
|
src = _playlist(db, user, "Source", ["a", "b"], yt_playlist_id="PLs")
|
||||||
|
dst = _playlist(db, user, "Target", ["b"], yt_playlist_id="PLd")
|
||||||
|
_synced(db, src)
|
||||||
|
_synced(db, dst)
|
||||||
|
client = as_user(user)
|
||||||
|
|
||||||
|
moved = client.post(
|
||||||
|
f"/api/playlists/{src.id}/items/move-bulk",
|
||||||
|
json={"video_ids": ["a"], "target_playlist_id": dst.id},
|
||||||
|
).json()
|
||||||
|
assert moved["moved"] == ["a"]
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Playlist, src.id).dirty is True
|
||||||
|
assert db.get(Playlist, dst.id).dirty is True
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
f"/api/playlists/{src.id}/items/undo-move",
|
||||||
|
json={"from_playlist_id": dst.id, "moved": ["a"], "positions": [0]},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Playlist, src.id).dirty is False
|
||||||
|
assert db.get(Playlist, dst.id).dirty is False
|
||||||
|
|
||||||
|
|
||||||
def test_undo_move_is_one_transaction(db, make_user, as_user, monkeypatch):
|
def test_undo_move_is_one_transaction(db, make_user, as_user, monkeypatch):
|
||||||
user = make_user()
|
user = make_user()
|
||||||
_seed_videos(db, ["a"])
|
_seed_videos(db, ["a"])
|
||||||
|
|||||||
@@ -102,17 +102,24 @@ PLEX = PlexPlaylist.__table__.name
|
|||||||
"call",
|
"call",
|
||||||
[
|
[
|
||||||
lambda c, pid, other: c.post(f"/api/playlists/{pid}/items", json={"video_id": "v2"}),
|
lambda c, pid, other: c.post(f"/api/playlists/{pid}/items", json={"video_id": "v2"}),
|
||||||
lambda c, pid, other: c.post(
|
|
||||||
f"/api/playlists/{pid}/items", json={"video_id": "v2", "position": 0}
|
|
||||||
),
|
|
||||||
lambda c, pid, other: c.post("/api/playlists/watch-later", json={"video_id": "v2"}),
|
lambda c, pid, other: c.post("/api/playlists/watch-later", json={"video_id": "v2"}),
|
||||||
lambda c, pid, other: c.post(
|
lambda c, pid, other: c.post(
|
||||||
f"/api/playlists/{pid}/items/move-bulk",
|
f"/api/playlists/{pid}/items/move-bulk",
|
||||||
json={"video_ids": ["v1"], "target_playlist_id": other},
|
json={"video_ids": ["v1"], "target_playlist_id": other},
|
||||||
),
|
),
|
||||||
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
|
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
|
||||||
|
lambda c, pid, other: c.post(
|
||||||
|
f"/api/playlists/{pid}/items/remove-bulk", json={"video_ids": ["v1"]}
|
||||||
|
),
|
||||||
|
lambda c, pid, other: c.post(
|
||||||
|
f"/api/playlists/{pid}/items/restore", json={"video_ids": ["v2"], "positions": [0]}
|
||||||
|
),
|
||||||
|
lambda c, pid, other: c.post(
|
||||||
|
f"/api/playlists/{pid}/items/undo-move",
|
||||||
|
json={"from_playlist_id": other, "moved": ["v2"], "positions": [0]},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
ids=["add", "add-at", "watch-later", "move", "reorder"],
|
ids=["add", "watch-later", "move", "reorder", "remove-bulk", "restore", "undo-move"],
|
||||||
)
|
)
|
||||||
def test_a_youtube_playlist_write_locks_the_playlist(db, make_user, as_user, locks, call):
|
def test_a_youtube_playlist_write_locks_the_playlist(db, make_user, as_user, locks, call):
|
||||||
user = make_user()
|
user = make_user()
|
||||||
@@ -169,9 +176,6 @@ PLEX_ITEMS = PlexPlaylistItem.__table__.name
|
|||||||
"call",
|
"call",
|
||||||
[
|
[
|
||||||
lambda c, pid, other: c.post(f"/api/playlists/{pid}/items", json={"video_id": "v1"}),
|
lambda c, pid, other: c.post(f"/api/playlists/{pid}/items", json={"video_id": "v1"}),
|
||||||
lambda c, pid, other: c.post(
|
|
||||||
f"/api/playlists/{pid}/items", json={"video_id": "v2", "position": 0}
|
|
||||||
),
|
|
||||||
lambda c, pid, other: c.delete(f"/api/playlists/{pid}/items/v1"),
|
lambda c, pid, other: c.delete(f"/api/playlists/{pid}/items/v1"),
|
||||||
lambda c, pid, other: c.post(
|
lambda c, pid, other: c.post(
|
||||||
f"/api/playlists/{pid}/items/move-bulk",
|
f"/api/playlists/{pid}/items/move-bulk",
|
||||||
@@ -180,8 +184,27 @@ PLEX_ITEMS = PlexPlaylistItem.__table__.name
|
|||||||
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
|
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
|
||||||
# the ORM cascade deletes the items before the list row
|
# the ORM cascade deletes the items before the list row
|
||||||
lambda c, pid, other: c.delete(f"/api/playlists/{pid}"),
|
lambda c, pid, other: c.delete(f"/api/playlists/{pid}"),
|
||||||
|
lambda c, pid, other: c.post(
|
||||||
|
f"/api/playlists/{pid}/items/remove-bulk", json={"video_ids": ["v1"]}
|
||||||
|
),
|
||||||
|
lambda c, pid, other: c.post(
|
||||||
|
f"/api/playlists/{pid}/items/restore", json={"video_ids": ["v2"], "positions": [0]}
|
||||||
|
),
|
||||||
|
lambda c, pid, other: c.post(
|
||||||
|
f"/api/playlists/{pid}/items/undo-move",
|
||||||
|
json={"from_playlist_id": other, "moved": ["v2"], "positions": [0]},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ids=[
|
||||||
|
"add-present",
|
||||||
|
"remove",
|
||||||
|
"move",
|
||||||
|
"reorder",
|
||||||
|
"delete",
|
||||||
|
"remove-bulk",
|
||||||
|
"restore",
|
||||||
|
"undo-move",
|
||||||
],
|
],
|
||||||
ids=["add-present", "add-at", "remove", "move", "reorder", "delete"],
|
|
||||||
)
|
)
|
||||||
def test_a_youtube_item_write_locks_the_playlist_before_any_item(
|
def test_a_youtube_item_write_locks_the_playlist_before_any_item(
|
||||||
db, make_user, as_user, statements, call
|
db, make_user, as_user, statements, call
|
||||||
@@ -195,15 +218,23 @@ def test_a_youtube_item_write_locks_the_playlist_before_any_item(
|
|||||||
assert _locked_before_items(statements, YT, YT_ITEMS), statements
|
assert _locked_before_items(statements, YT, YT_ITEMS), statements
|
||||||
|
|
||||||
|
|
||||||
def test_a_move_locks_both_lists_in_one_ordered_statement(db, make_user, as_user, statements):
|
@pytest.mark.parametrize(
|
||||||
"""Two moves A→B and B→A each locking "its" list first would lock crosswise."""
|
("path", "body"),
|
||||||
|
[
|
||||||
|
("move-bulk", lambda other: {"video_ids": ["v1"], "target_playlist_id": other}),
|
||||||
|
("undo-move", lambda other: {"from_playlist_id": other, "moved": ["v2"], "positions": [0]}),
|
||||||
|
],
|
||||||
|
ids=["move", "undo"],
|
||||||
|
)
|
||||||
|
def test_a_move_locks_both_lists_in_one_ordered_statement(
|
||||||
|
db, make_user, as_user, statements, path, body
|
||||||
|
):
|
||||||
|
"""Two moves A→B and B→A each locking "its" list first would lock crosswise, and an Undo runs the
|
||||||
|
other way round from a move by its nature."""
|
||||||
user = make_user()
|
user = make_user()
|
||||||
pid, other = _yt(db, user)
|
pid, other = _yt(db, user)
|
||||||
statements.clear()
|
statements.clear()
|
||||||
r = as_user(user).post(
|
r = as_user(user).post(f"/api/playlists/{pid}/items/{path}", json=body(other))
|
||||||
f"/api/playlists/{pid}/items/move-bulk",
|
|
||||||
json={"video_ids": ["v1"], "target_playlist_id": other},
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
lock = _locked_before_items(statements, YT, YT_ITEMS)
|
lock = _locked_before_items(statements, YT, YT_ITEMS)
|
||||||
assert lock and "ORDER BY" in lock, lock
|
assert lock and "ORDER BY" in lock, lock
|
||||||
|
|||||||
@@ -210,9 +210,8 @@ def test_an_add_is_idempotent_and_lands_where_asked(db, make_user):
|
|||||||
pl = service.create_playlist(db, user, "L")
|
pl = service.create_playlist(db, user, "L")
|
||||||
assert service.add_item(db, pl, "a", mark_dirty=False) is True
|
assert service.add_item(db, pl, "a", mark_dirty=False) is True
|
||||||
assert service.add_item(db, pl, "a", mark_dirty=False) is False
|
assert service.add_item(db, pl, "a", mark_dirty=False) is False
|
||||||
service.add_to_playlist(db, pl, "b", None)
|
assert service.add_item(db, pl, "b", mark_dirty=True) is True
|
||||||
service.add_to_playlist(db, pl, "c", 0)
|
assert service.restore_items(db, pl, ["c", "a"], [0, 2]) == 1 # "a" is there: it stays put
|
||||||
service.add_to_playlist(db, pl, "a", 2) # already there: stays put
|
|
||||||
assert _uncommitted(db)
|
assert _uncommitted(db)
|
||||||
db.commit()
|
db.commit()
|
||||||
assert _ids(db, pl) == ["c", "a", "b"]
|
assert _ids(db, pl) == ["c", "a", "b"]
|
||||||
|
|||||||
+13
-8
@@ -543,7 +543,10 @@ of a full-width panel and nothing on the right.
|
|||||||
menu still acts on that row alone. While anything is selected the toolbar's second row is the action band:
|
menu still acts on that row alone. While anything is selected the toolbar's second row is the action band:
|
||||||
the count (and how many a filter hides), the last move's target as one click, Move to, Remove, Clear
|
the count (and how many a filter hides), the last move's target as one click, Move to, Remove, Clear
|
||||||
selection. An action clears the selection, undoes by its toast and, when it fails, ticks its rows again.
|
selection. An action clears the selection, undoes by its toast and, when it fails, ticks its rows again.
|
||||||
Delete takes the selection when there is one, else the row under the keyboard, with no confirm.
|
An Undo brings back what is still there to bring: a video the move's target no longer holds stays where
|
||||||
|
it went, and a warning says how many came back.
|
||||||
|
Delete takes the selection when there is one, else the row under the keyboard, with no confirm. With every
|
||||||
|
tick hidden by a filter it removes nothing and says so. An Esc during a drag puts the drag down, and only that.
|
||||||
Below `xl` the band's buttons are glyphs named in their tooltips, and a filter's hidden count is "+N" with
|
Below `xl` the band's buttons are glyphs named in their tooltips, and a filter's hidden count is "+N" with
|
||||||
its sentence in the tooltip. The row the band replaces keeps its height, invisible, so a tick moves no row.
|
its sentence in the tooltip. The row the band replaces keeps its height, invisible, so a tick moves no row.
|
||||||
On a phone the count is its number (the words read out, and in the tooltip) and the undo pair stays out.
|
On a phone the count is its number (the words read out, and in the tooltip) and the undo pair stays out.
|
||||||
@@ -849,10 +852,12 @@ of a full-width panel and nothing on the right.
|
|||||||
| 2026-11-01 | Toolbar group glyphs (owner D9 a, D10 a, D11 a) | The feed's, channel page's and Plex Browse's filter pills name their group by a glyph (`Eye` watch state, `Shapes` content types, `ArrowDownUp` sort), the word in the tooltip and the accessible name; Sort + View end the toolbar's row, the chip row shows only with a chip; a channel page has no count pill | The value alone; Sort + View on a row of their own | "Watch state:" and "Sort:" cost a tablet row its room; the value says enough once the glyph names the group; the channel header already counts the videos |
|
| 2026-11-01 | Toolbar group glyphs (owner D9 a, D10 a, D11 a) | The feed's, channel page's and Plex Browse's filter pills name their group by a glyph (`Eye` watch state, `Shapes` content types, `ArrowDownUp` sort), the word in the tooltip and the accessible name; Sort + View end the toolbar's row, the chip row shows only with a chip; a channel page has no count pill | The value alone; Sort + View on a row of their own | "Watch state:" and "Sort:" cost a tablet row its room; the value says enough once the glyph names the group; the channel header already counts the videos |
|
||||||
| 2026-11-01 | Channel header on a tablet (owner E2) | Below `xl` the action row drops its two words: "Prio" (the stepper's tooltip names it) and "Subscribed" (a ✓ glyph; its name in the aria-label, Unsubscribe in the tooltip); the tags keep the row's rest, folding into "+N" | The tag lane on a full line of its own below ~12rem; the tags under the channel name | On an iPad (820 px, 44 px targets) the lane got 29 px, a sliver; now 130 px, and every tag shows at 1180 |
|
| 2026-11-01 | Channel header on a tablet (owner E2) | Below `xl` the action row drops its two words: "Prio" (the stepper's tooltip names it) and "Subscribed" (a ✓ glyph; its name in the aria-label, Unsubscribe in the tooltip); the tags keep the row's rest, folding into "+N" | The tag lane on a full line of its own below ~12rem; the tags under the channel name | On an iPad (820 px, 44 px targets) the lane got 29 px, a sliver; now 130 px, and every tag shows at 1180 |
|
||||||
| 2026-11-01 | Clear watch progress (owner E a) | A video menu's Reset reads "Clear watch progress" in English (HU "Megtekintés visszaállítása" stays); "Reset view" is only a page's view reset (sort, filters, grouping) | "Reset view" for both; the playlist page's view reset renamed instead | On a playlist page the two stood one right-click apart under one name, and one of them erases the video's progress with no undo |
|
| 2026-11-01 | Clear watch progress (owner E a) | A video menu's Reset reads "Clear watch progress" in English (HU "Megtekintés visszaállítása" stays); "Reset view" is only a page's view reset (sort, filters, grouping) | "Reset view" for both; the playlist page's view reset renamed instead | On a playlist page the two stood one right-click apart under one name, and one of them erases the video's progress with no undo |
|
||||||
| 2026-11-01 | Clear watch progress clears only that (owner A) | The row shows only on a video with a resume point and clears that alone: a hidden video stays hidden, a watched one watched. Un-hiding and un-watching have rows of their own |
|
| 2026-11-01 | Clear watch progress clears only that (owner A) | The row shows only on a video with a resume point and clears that alone: a hidden video stays hidden, a watched one watched. Un-hiding and un-watching have rows of their own | The old Reset, which also un-hid and un-watched | A hidden video stays hidden: what the row's name says is all it does |
|
||||||
| 2026-11-01 | Selecting a list's rows (owner D2 a) | An always visible box first in the row and one over the column for the rows shown; Shift+click a range, `x` the row under the keyboard, Esc clears; the selected row wears `accent/12` with its tick. Rows a filter hides stay selected, an action covers those shown; Watch later offers no selecting. |
|
| 2026-11-01 | Selecting a list's rows (owner D2 a) | An always visible box first in the row and one over the column for the rows shown; Shift+click a range, `x` the row under the keyboard, Esc clears; the selected row wears `accent/12` with its tick. Rows a filter hides stay selected, an action covers those shown; Watch later offers no selecting. | A "Select" button that shows the boxes on demand | Found at once and works on touch, with no mode to leave; costs about 2 rem a row |
|
||||||
| 2026-11-01 | Selection band (owner D1 a, D3 a, D4 a) | The toolbar's second row while rows are selected, no floating bar: move (the last target as one click) and remove, no copy; both undo back to the places, a video the target held before stays there; a single row's move undoes too. |
|
| 2026-11-01 | Selection band (owner D1 a, D3 a, D4 a) | The toolbar's second row while rows are selected, no floating bar: move (the last target as one click) and remove, no copy; both undo back to the places, a video the target held before stays there; a single row's move undoes too. | A floating bar at the bottom; copying to a list too; no undo on a move | It covers no row, and the player and the notices live at the bottom; twenty videos sent to the wrong list need a way back to their places |
|
||||||
| 2026-11-01 | Grip on a phone (owner A) | Below a 24rem table a playlist row has no grip: the title keeps its width, and the row menu keeps the moves. |
|
| 2026-11-01 | Grip on a phone (owner A) | Below a 24rem table a playlist row has no grip: the title keeps its width, and the row menu keeps the moves. | The grip and its drag kept, the title at 44 px | The title gets its width back, and the rail is shut on a phone, so a drag had nowhere to go |
|
||||||
| 2026-11-01 | Selection band on a tablet (owner A) | Below `xl` the band is glyphs on one row, the names in tooltip and aria-label, as on the channel header; the replaced row stays in the same grid cell, invisible, so the first tick moves no row under the second tap. |
|
| 2026-11-01 | Selection band on a tablet (owner A) | Below `xl` the band is glyphs on one row, the names in tooltip and aria-label, as on the channel header; the replaced row stays in the same grid cell, invisible, so the first tick moves no row under the second tap. | The buttons in words, wrapping to a second row | The first tick pushed the list from under the second tap |
|
||||||
| 2026-11-01 | Playlist heading on a narrow window (owner A) | The list's name had no width left: below `xl` Unsynced is its dot (the word read out, the hint in the tooltip); on a phone the cover is gone and Play all is its glyph, named in the tooltip. |
|
| 2026-11-01 | Playlist heading on a narrow window (owner A) | The list's name had no width left: below `xl` Unsynced is its dot (the word read out, the hint in the tooltip); on a phone the cover is gone and Play all is its glyph, named in the tooltip. | Every word kept, the list's name at no width | A heading without its name says nothing |
|
||||||
| 2026-11-02 | Dragging onto a list (owner D5 a, D6 a) | The grip carries a row, or every selected row with a selected one, onto a list in the rail; the toast undoes. Many dropped inside the list stay; a coarse pointer's drawer never peeks, so its rail is a target only when pinned. |
|
| 2026-11-02 | Dragging onto a list (owner D5 a, D6 a) | The grip carries a row, or every selected row with a selected one, onto a list in the rail; the toast undoes. Many dropped inside the list stay; a coarse pointer's drawer never peeks, so its rail is a target only when pinned. | Only the grabbed row going along; the grip off under a view | One row moving out of three lit ones misleads, and a filter is how the rows to move are found |
|
||||||
|
| 2026-11-02 | Delete with every tick hidden (owner a) | It removes nothing and a warning says the filter hides them | Taking the row under the keyboard | While the band shows a selection, Delete means that selection: a row no one ticked must not go |
|
||||||
|
| 2026-11-02 | Undo of a video gone further (owner a) | Only what the target still holds comes back, and a warning says how many | Everything back, whatever it did since | A move skips a video that left the source; the same rule back keeps a video out of lists the reader never put it in |
|
||||||
|
|||||||
+2
-2
@@ -108,8 +108,8 @@ project checks needs a row.
|
|||||||
|
|
||||||
| Scope | Narrowed | Why | Measured |
|
| Scope | Narrowed | Why | Measured |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| check | e2e (Playwright) not in `check` | It needs the :8080 stack built from the tree (minutes). It runs in `check-full`, and `release` rebuilds the stack and requires it (E32 D2) — nothing ships without it. | 9 specs: 6 × 3 engines, 3 on chromium only (the row below) |
|
| check | e2e (Playwright) not in `check` | It needs the :8080 stack built from the tree (minutes). It runs in `check-full`, and `release` rebuilds the stack and requires it (E32 D2) — nothing ships without it. | 12 specs: 9 × 3 engines, 3 on chromium only (the row below) |
|
||||||
| e2e | `a11y.spec.ts` `member.spec.ts` `devices.spec.ts` run on chromium only (`CHROMIUM_ONLY`, the webkit and ipad projects' `testIgnore`) | What they hold does not change with the engine: the axe scan reads the DOM and the accessibility tree, which the engines build alike; the member spec checks which pages and requests a role gets; the devices spec opens its own desktop and iPad contexts. Run three times they would triple their time and catch nothing more. What an engine does change (layout, events) the other specs hold on all three. | 3 of 9 specs |
|
| e2e | `a11y.spec.ts` `member.spec.ts` `devices.spec.ts` run on chromium only (`CHROMIUM_ONLY`, the webkit and ipad projects' `testIgnore`) | What they hold does not change with the engine: the axe scan reads the DOM and the accessibility tree, which the engines build alike; the member spec checks which pages and requests a role gets; the devices spec opens its own desktop and iPad contexts. Run three times they would triple their time and catch nothing more. What an engine does change (layout, events) the other specs hold on all three. | 3 of 12 specs |
|
||||||
| pushes | no server-side gate (no CI) | Owner decision 2026-10-04. The gate (`scripts/check.sh`) runs on the dev machine before work is handed over, and `release` runs it, then e2e, before anything is merged and Trivy before the image is pushed — the CI only repeated that, after the merge. Its runner shared the git host's container, and one run there starved Gitea of memory until it stopped answering. What it added — a fresh `npm ci` on Linux — the image build does at publish. | — |
|
| pushes | no server-side gate (no CI) | Owner decision 2026-10-04. The gate (`scripts/check.sh`) runs on the dev machine before work is handed over, and `release` runs it, then e2e, before anything is merged and Trivy before the image is pushed — the CI only repeated that, after the merge. Its runner shared the git host's container, and one run there starved Gitea of memory until it stopped answering. What it added — a fresh `npm ci` on Linux — the image build does at publish. | — |
|
||||||
| coverage | not in the gate; no threshold | Owner decision E32 D4: never enforced; "every regression test seen red" is the bar, and a percentage invites tests written to move it. Out of the lanes since E35: read once, by hand, it found 5 bugs; as a lane it was a number nobody acted on, and the slowest vitest run (the one that flaked). On demand: docs/development.md. | frontend 80% / backend 78% of lines (E32 S1) |
|
| coverage | not in the gate; no threshold | Owner decision E32 D4: never enforced; "every regression test seen red" is the bar, and a percentage invites tests written to move it. Out of the lanes since E35: read once, by hand, it found 5 bugs; as a lane it was a number nobody acted on, and the slowest vitest run (the one that flaked). On demand: docs/development.md. | frontend 80% / backend 78% of lines (E32 S1) |
|
||||||
| CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) |
|
| CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) |
|
||||||
|
|||||||
@@ -94,6 +94,50 @@ async function pinOnTouch(page: Page, project: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test.describe("acting on the selected rows", { tag: "@playlists" }, () => {
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
for (const id of made.splice(0)) await page.request.delete(`/api/playlists/${String(id)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const box = (page: Page, title: string) =>
|
||||||
|
page.getByRole("checkbox", { name: `Select “${title}”` });
|
||||||
|
|
||||||
|
test("a Shift range, the band's Remove, and its Undo back to the places", async ({
|
||||||
|
page,
|
||||||
|
}, info) => {
|
||||||
|
const { videos, from } = await setUp(page, `band ${info.project.name}`, info.project.name);
|
||||||
|
await box(page, videos[0]!.title).click();
|
||||||
|
await box(page, videos[1]!.title).click({ modifiers: ["Shift"] });
|
||||||
|
await expect(box(page, videos[1]!.title)).toBeChecked();
|
||||||
|
await expect(box(page, videos[2]!.title)).not.toBeChecked();
|
||||||
|
await page.getByRole("button", { name: "Remove", exact: true }).click();
|
||||||
|
await expect(page.locator("tbody tr")).toHaveCount(1);
|
||||||
|
await expect.poll(() => contents(page, from)).toEqual([videos[2]!.id]);
|
||||||
|
await page.getByRole("button", { name: "Undo", exact: true }).last().click();
|
||||||
|
await expect.poll(() => contents(page, from)).toEqual(videos.map((v) => v.id));
|
||||||
|
await expect(page.locator("tbody tr")).toHaveCount(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("x ticks the walked row, Esc clears, and Delete takes the selection and goes on", async ({
|
||||||
|
page,
|
||||||
|
}, info) => {
|
||||||
|
const { videos, from } = await setUp(page, `keys ${info.project.name}`, info.project.name);
|
||||||
|
const firstRow = page.locator("tbody tr").first();
|
||||||
|
await firstRow.focus();
|
||||||
|
await page.keyboard.press("x");
|
||||||
|
await expect(box(page, videos[0]!.title)).toBeChecked();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(box(page, videos[0]!.title)).not.toBeChecked();
|
||||||
|
await page.keyboard.press("x");
|
||||||
|
await page.keyboard.press("j");
|
||||||
|
await page.keyboard.press("x");
|
||||||
|
await page.keyboard.press("Delete");
|
||||||
|
await expect.poll(() => contents(page, from)).toEqual([videos[2]!.id]);
|
||||||
|
await expect(page.locator("tbody tr")).toHaveCount(1);
|
||||||
|
await expect(firstRow).toBeFocused();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test.describe("dragging onto a list", { tag: "@playlists" }, () => {
|
test.describe("dragging onto a list", { tag: "@playlists" }, () => {
|
||||||
test.afterEach(async ({ page }) => {
|
test.afterEach(async ({ page }) => {
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
@@ -139,6 +183,8 @@ test.describe("dragging onto a list", { tag: "@playlists" }, () => {
|
|||||||
await carryToRail(page, grip, target);
|
await carryToRail(page, grip, target);
|
||||||
const image = page.getByText("2 videos", { exact: true });
|
const image = page.getByText("2 videos", { exact: true });
|
||||||
await expect(image).toBeVisible();
|
await expect(image).toBeVisible();
|
||||||
|
// Above every layer, by the tooltip's token: the overlay wrapping it takes the level.
|
||||||
|
expect(await image.evaluate((el) => getComputedStyle(el.parentElement!).zIndex)).toBe("100");
|
||||||
// Below the pointer, so it leaves the list's name to read.
|
// Below the pointer, so it leaves the list's name to read.
|
||||||
const { y } = await centre(target);
|
const { y } = await centre(target);
|
||||||
expect((await image.boundingBox())!.y).toBeGreaterThan(y);
|
expect((await image.boundingBox())!.y).toBeGreaterThan(y);
|
||||||
@@ -184,6 +230,9 @@ test.describe("dragging onto a list", { tag: "@playlists" }, () => {
|
|||||||
`drag cancel ${info.project.name}`,
|
`drag cancel ${info.project.name}`,
|
||||||
info.project.name,
|
info.project.name,
|
||||||
);
|
);
|
||||||
|
// A tick on another row: the Esc is the drag's, and leaves the selection alone.
|
||||||
|
const ticked = page.getByRole("checkbox", { name: `Select “${videos[2]!.title}”` });
|
||||||
|
await ticked.click();
|
||||||
const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false });
|
const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false });
|
||||||
await carryToRail(page, grip, target);
|
await carryToRail(page, grip, target);
|
||||||
await expect(target).toHaveClass(/ring-2/);
|
await expect(target).toHaveClass(/ring-2/);
|
||||||
@@ -192,6 +241,7 @@ test.describe("dragging onto a list", { tag: "@playlists" }, () => {
|
|||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
expect(await contents(page, from)).toEqual(videos.map((v) => v.id));
|
expect(await contents(page, from)).toEqual(videos.map((v) => v.id));
|
||||||
expect(await contents(page, to)).toEqual([]);
|
expect(await contents(page, to)).toEqual([]);
|
||||||
|
await expect(ticked).toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a key still reorders a row, alone", async ({ page }, info) => {
|
test("a key still reorders a row, alone", async ({ page }, info) => {
|
||||||
|
|||||||
@@ -94,10 +94,13 @@ describe("videoWrites", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("marks a bulk removal from Watch later, and its restore", async () => {
|
it("marks a bulk removal from Watch later, and its restore", async () => {
|
||||||
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({ ok: true } as never);
|
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
removed: ["X"],
|
||||||
|
} as never);
|
||||||
vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true } as never);
|
vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true } as never);
|
||||||
const qc = new QueryClient();
|
const qc = new QueryClient();
|
||||||
await removePlaylistItems(qc, WATCH_LATER, ["X"]);
|
await removePlaylistItems(qc, WATCH_LATER, ["X", "gone"]);
|
||||||
expect(marks(qc)).toEqual({ X: { saved: false } });
|
expect(marks(qc)).toEqual({ X: { saved: false } });
|
||||||
await restorePlaylistItems(qc, WATCH_LATER, ["X"], [0]);
|
await restorePlaylistItems(qc, WATCH_LATER, ["X"], [0]);
|
||||||
expect(marks(qc)).toEqual({ X: { saved: true } });
|
expect(marks(qc)).toEqual({ X: { saved: true } });
|
||||||
|
|||||||
@@ -43,10 +43,8 @@ const markIfWatchLater = (qc: QueryClient, list: ListRef, id: string, saved: boo
|
|||||||
if (list.kind === "watch_later") markVideo(qc, id, { saved });
|
if (list.kind === "watch_later") markVideo(qc, id, { saved });
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Add a video to a playlist; `position` (an index in the stored order) puts it back where a
|
export function addPlaylistItem(qc: QueryClient, list: ListRef, id: string) {
|
||||||
* removal took it from. */
|
return api.addToPlaylist(list.id, id).then((r) => {
|
||||||
export function addPlaylistItem(qc: QueryClient, list: ListRef, id: string, position?: number) {
|
|
||||||
return api.addToPlaylist(list.id, id, position).then((r) => {
|
|
||||||
markIfWatchLater(qc, list, id, true);
|
markIfWatchLater(qc, list, id, true);
|
||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
@@ -59,10 +57,10 @@ export function removePlaylistItem(qc: QueryClient, list: ListRef, id: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Take videos out of a playlist, in one request. */
|
/** Take videos out of a playlist, in one request. Marks what the server says went. */
|
||||||
export function removePlaylistItems(qc: QueryClient, list: ListRef, ids: string[]) {
|
export function removePlaylistItems(qc: QueryClient, list: ListRef, ids: string[]) {
|
||||||
return api.removeManyFromPlaylist(list.id, ids).then((r) => {
|
return api.removeManyFromPlaylist(list.id, ids).then((r) => {
|
||||||
for (const id of ids) markIfWatchLater(qc, list, id, false);
|
for (const id of r.removed) markIfWatchLater(qc, list, id, false);
|
||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
"listStep": "Next / previous item",
|
"listStep": "Next / previous item",
|
||||||
"gridMove": "Move around the list",
|
"gridMove": "Move around the list",
|
||||||
"open": "Open the current item",
|
"open": "Open the current item",
|
||||||
"listRemove": "Remove the selected rows, or else the current one",
|
"listRemove": "Remove from the list",
|
||||||
"listSelect": "Select or deselect the current item",
|
"listSelect": "Select or deselect the current item",
|
||||||
"listClearSelection": "Clear the selection",
|
"listClearSelection": "Clear the selection",
|
||||||
"cap": {
|
"cap": {
|
||||||
|
|||||||
@@ -117,6 +117,15 @@
|
|||||||
"removedManyToast_other": "{{count}} videos removed from the playlist",
|
"removedManyToast_other": "{{count}} videos removed from the playlist",
|
||||||
"moveNoneLeft": "Nothing moved: they aren’t in this playlist any more",
|
"moveNoneLeft": "Nothing moved: they aren’t in this playlist any more",
|
||||||
"removeNoneLeft": "Nothing removed: they aren’t in this playlist any more",
|
"removeNoneLeft": "Nothing removed: they aren’t in this playlist any more",
|
||||||
|
"selectionCleared": "Selection cleared",
|
||||||
|
"removeAllHidden": "The selected rows are hidden by the filter",
|
||||||
|
"undoNoneBack": "Nothing came back: those videos aren’t where they were any more",
|
||||||
|
"undoSomeBack_one": "Only {{count}} video came back: the rest aren’t where they were any more",
|
||||||
|
"undoSomeBack_other": "Only {{count}} videos came back: the rest aren’t where they were any more",
|
||||||
|
"dragCancelledMany_one": "Move cancelled; {{count}} video stays where it was",
|
||||||
|
"dragCancelledMany_other": "Move cancelled; {{count}} videos stay where they were",
|
||||||
|
"moveTargetsFailed": "Your playlists couldn’t be loaded",
|
||||||
|
"moveTargetsLoading": "Loading your playlists…",
|
||||||
"moveManyFailed": "Couldn't move the videos",
|
"moveManyFailed": "Couldn't move the videos",
|
||||||
"removeManyFailed": "Couldn't remove the videos",
|
"removeManyFailed": "Couldn't remove the videos",
|
||||||
"undoMoveFailed": "Couldn't move the video back",
|
"undoMoveFailed": "Couldn't move the video back",
|
||||||
|
|||||||
@@ -35,8 +35,8 @@
|
|||||||
"listStep": "Következő / előző elem",
|
"listStep": "Következő / előző elem",
|
||||||
"gridMove": "Mozgás a listában",
|
"gridMove": "Mozgás a listában",
|
||||||
"open": "Az aktuális elem megnyitása",
|
"open": "Az aktuális elem megnyitása",
|
||||||
"listRemove": "A kijelölt sorok, vagy az aktuális eltávolítása",
|
"listRemove": "Eltávolítás a listából",
|
||||||
"listSelect": "Az aktuális elem kijelölése vagy levétele",
|
"listSelect": "Az aktuális elem kijelölése, vagy a kijelölés törlése",
|
||||||
"listClearSelection": "A kijelölés törlése",
|
"listClearSelection": "A kijelölés törlése",
|
||||||
"cap": {
|
"cap": {
|
||||||
"space": "Szóköz",
|
"space": "Szóköz",
|
||||||
|
|||||||
@@ -117,6 +117,15 @@
|
|||||||
"removedManyToast_other": "{{count}} videó eltávolítva a listáról",
|
"removedManyToast_other": "{{count}} videó eltávolítva a listáról",
|
||||||
"moveNoneLeft": "Nem történt áthelyezés: ezek már nincsenek ebben a listában",
|
"moveNoneLeft": "Nem történt áthelyezés: ezek már nincsenek ebben a listában",
|
||||||
"removeNoneLeft": "Nem történt eltávolítás: ezek már nincsenek ebben a listában",
|
"removeNoneLeft": "Nem történt eltávolítás: ezek már nincsenek ebben a listában",
|
||||||
|
"selectionCleared": "Kijelölés törölve",
|
||||||
|
"removeAllHidden": "A kijelölt sorokat a szűrő elrejti",
|
||||||
|
"undoNoneBack": "Semmi nem került vissza: ezek a videók már nincsenek ott, ahol voltak",
|
||||||
|
"undoSomeBack_one": "Csak {{count}} videó került vissza: a többi már nincs ott, ahol volt",
|
||||||
|
"undoSomeBack_other": "Csak {{count}} videó került vissza: a többi már nincs ott, ahol volt",
|
||||||
|
"dragCancelledMany_one": "Áthelyezés megszakítva; {{count}} videó a helyén marad",
|
||||||
|
"dragCancelledMany_other": "Áthelyezés megszakítva; {{count}} videó a helyén marad",
|
||||||
|
"moveTargetsFailed": "Nem sikerült betölteni a listáidat",
|
||||||
|
"moveTargetsLoading": "A listáid betöltése…",
|
||||||
"moveManyFailed": "Nem sikerült áthelyezni a videókat",
|
"moveManyFailed": "Nem sikerült áthelyezni a videókat",
|
||||||
"removeManyFailed": "Nem sikerült eltávolítani a videókat",
|
"removeManyFailed": "Nem sikerült eltávolítani a videókat",
|
||||||
"undoMoveFailed": "Nem sikerült visszahelyezni a videót",
|
"undoMoveFailed": "Nem sikerült visszahelyezni a videót",
|
||||||
|
|||||||
@@ -70,10 +70,10 @@ export const playlistsApi = {
|
|||||||
req(`/api/playlists/${id}${onYoutube ? "?on_youtube=true" : ""}`, { method: "DELETE" }),
|
req(`/api/playlists/${id}${onYoutube ? "?on_youtube=true" : ""}`, { method: "DELETE" }),
|
||||||
// `position` (an index in the stored order) puts it back where a removal took it from — the
|
// `position` (an index in the stored order) puts it back where a removal took it from — the
|
||||||
// list page's Undo; omitted, it is appended.
|
// list page's Undo; omitted, it is appended.
|
||||||
addToPlaylist: (id: number, videoId: string, position?: number) =>
|
addToPlaylist: (id: number, videoId: string) =>
|
||||||
req(`/api/playlists/${id}/items`, {
|
req(`/api/playlists/${id}/items`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ video_id: videoId, ...(position != null && { position }) }),
|
body: JSON.stringify({ video_id: videoId }),
|
||||||
}),
|
}),
|
||||||
removeFromPlaylist: (id: number, videoId: string) =>
|
removeFromPlaylist: (id: number, videoId: string) =>
|
||||||
req(`/api/playlists/${id}/items/${videoId}`, { method: "DELETE" }),
|
req(`/api/playlists/${id}/items/${videoId}`, { method: "DELETE" }),
|
||||||
@@ -115,7 +115,7 @@ export const playlistsApi = {
|
|||||||
removeManyFromPlaylist: (
|
removeManyFromPlaylist: (
|
||||||
id: number,
|
id: number,
|
||||||
videoIds: string[],
|
videoIds: string[],
|
||||||
): Promise<{ ok: true; playlist_id: number; removed: number }> =>
|
): Promise<{ ok: true; playlist_id: number; removed: string[] }> =>
|
||||||
req(`/api/playlists/${id}/items/remove-bulk`, {
|
req(`/api/playlists/${id}/items/remove-bulk`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ video_ids: videoIds }),
|
body: JSON.stringify({ video_ids: videoIds }),
|
||||||
|
|||||||
@@ -13,15 +13,17 @@ afterEach(cleanup);
|
|||||||
function Rows({
|
function Rows({
|
||||||
selection,
|
selection,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
rows = 3,
|
||||||
}: {
|
}: {
|
||||||
selection?: NavSelection;
|
selection?: NavSelection;
|
||||||
onDelete?: (index: number) => void;
|
onDelete?: (index: number) => void;
|
||||||
|
rows?: number;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useListNav(ref, { enabled: true, selection, onDelete });
|
useListNav(ref, { enabled: true, selection, onDelete });
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
{[0, 1, 2].map((i) => (
|
{Array.from({ length: rows }, (_, i) => (
|
||||||
<div key={i} {...navItemAttrs(i)} data-testid={`r${i}`}>
|
<div key={i} {...navItemAttrs(i)} data-testid={`r${i}`}>
|
||||||
<button data-testid={`r${i}-action`}>action</button>
|
<button data-testid={`r${i}-action`}>action</button>
|
||||||
<input data-testid={`r${i}-input`} />
|
<input data-testid={`r${i}-input`} />
|
||||||
@@ -125,6 +127,26 @@ describe("useListNav · selecting", () => {
|
|||||||
await waitFor(() => expect(document.activeElement).toBe(screen.getByTestId("r2")));
|
await waitFor(() => expect(document.activeElement).toBe(screen.getByTestId("r2")));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("goes on to the next row left, past what a selection took from above it", async () => {
|
||||||
|
const s = selection();
|
||||||
|
s.removeSelected.mockImplementation(() => {
|
||||||
|
// As a commit does it, after the key's handler: the two rows go, and the ones left are numbered
|
||||||
|
// from the top.
|
||||||
|
queueMicrotask(() => {
|
||||||
|
screen.getByTestId("r0").remove();
|
||||||
|
screen.getByTestId("r1").remove();
|
||||||
|
screen.getByTestId("r2").setAttribute("data-nav-index", "0");
|
||||||
|
screen.getByTestId("r3").setAttribute("data-nav-index", "1");
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
render(<Rows selection={s} rows={4} />);
|
||||||
|
const r2 = screen.getByTestId("r2");
|
||||||
|
screen.getByTestId("r1-box").focus();
|
||||||
|
press("Delete");
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(r2));
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves the focus where it is when something else took it meanwhile", async () => {
|
it("leaves the focus where it is when something else took it meanwhile", async () => {
|
||||||
render(<Rows />);
|
render(<Rows />);
|
||||||
const leaving = screen.getByTestId("r1");
|
const leaving = screen.getByTestId("r1");
|
||||||
|
|||||||
@@ -476,10 +476,14 @@ function onKey(e: KeyboardEvent): void {
|
|||||||
// How long a removal gets to take its row out of the DOM before the focus gives up following it.
|
// How long a removal gets to take its row out of the DOM before the focus gives up following it.
|
||||||
const REMOVE_FRAMES = 30;
|
const REMOVE_FRAMES = 30;
|
||||||
|
|
||||||
/** After the focused item was removed, focus the item now in its slot (the next one; the new last
|
/** After the focused item was removed, focus the item that followed it and is still there (the new
|
||||||
* when it was the last), so a second Delete takes the next row — the way a mail list behaves. Only
|
* last when none is), so a second Delete takes the next row — the way a mail list behaves. By element:
|
||||||
* while nothing else took the focus meanwhile: the removal fell back to <body>, nothing more. */
|
* rows a selection took from above it would shift an index past the ones left. Only while nothing else
|
||||||
|
* took the focus meanwhile: the removal fell back to <body>, nothing more. */
|
||||||
function refocusSlot(list: NavList, index: number, removed: HTMLElement): void {
|
function refocusSlot(list: NavList, index: number, removed: HTMLElement): void {
|
||||||
|
const items = ownItems(list.el);
|
||||||
|
const at = items.indexOf(removed);
|
||||||
|
const after = at < 0 ? [] : items.slice(at + 1);
|
||||||
let frames = 0;
|
let frames = 0;
|
||||||
const wait = () => {
|
const wait = () => {
|
||||||
if (removed.isConnected) {
|
if (removed.isConnected) {
|
||||||
@@ -487,6 +491,8 @@ function refocusSlot(list: NavList, index: number, removed: HTMLElement): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!focusIsUnclaimed()) return;
|
if (!focusIsUnclaimed()) return;
|
||||||
|
const next = after.find((el) => el.isConnected);
|
||||||
|
if (next) return focusItem(next);
|
||||||
const n = list.count();
|
const n = list.count();
|
||||||
if (n > 0) focusIndex(list, Math.min(index, n - 1));
|
if (n > 0) focusIndex(list, Math.min(index, n - 1));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -148,6 +148,9 @@ const list = (id: number, items: Video[]) => ({
|
|||||||
items,
|
items,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The server's answer to a removal: the rows that went. */
|
||||||
|
const wentOut = (...removed: string[]) => ({ ok: true, playlist_id: 1, removed });
|
||||||
|
|
||||||
let qc: QueryClient;
|
let qc: QueryClient;
|
||||||
|
|
||||||
function mount(
|
function mount(
|
||||||
@@ -158,17 +161,21 @@ function mount(
|
|||||||
detail,
|
detail,
|
||||||
linked = h.selectedId,
|
linked = h.selectedId,
|
||||||
listIn = false,
|
listIn = false,
|
||||||
|
rail,
|
||||||
}: {
|
}: {
|
||||||
strictOverCache?: boolean;
|
strictOverCache?: boolean;
|
||||||
detail?: (id: number) => Promise<unknown>;
|
detail?: (id: number) => Promise<unknown>;
|
||||||
linked?: number | null;
|
linked?: number | null;
|
||||||
/** The account's list already in, as the rail that fetched it leaves it. */
|
/** The account's list already in, as the rail that fetched it leaves it. */
|
||||||
listIn?: boolean;
|
listIn?: boolean;
|
||||||
|
/** What the account's list answers, when it is not the lists above. */
|
||||||
|
rail?: () => Promise<unknown>;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
h.linkedId = linked;
|
h.linkedId = linked;
|
||||||
vi.spyOn(api, "playlists").mockResolvedValue(
|
vi.spyOn(api, "playlists").mockImplementation(
|
||||||
Object.entries(lists).map(([id, items]) => list(Number(id), items)) as never,
|
(rail ??
|
||||||
|
(async () => Object.entries(lists).map(([id, items]) => list(Number(id), items)))) as never,
|
||||||
);
|
);
|
||||||
vi.spyOn(api, "playlist").mockImplementation(
|
vi.spyOn(api, "playlist").mockImplementation(
|
||||||
(detail ?? (async (id: number) => list(id, lists[id] ?? []))) as never,
|
(detail ?? (async (id: number) => list(id, lists[id] ?? []))) as never,
|
||||||
@@ -409,7 +416,7 @@ describe("Playlists — removing a video from inside the player", () => {
|
|||||||
it("removes it from the list the player was launched from, even after the selection moved", async () => {
|
it("removes it from the list the player was launched from, even after the selection moved", async () => {
|
||||||
// The selection can change under an open player (the command palette opens over it); the
|
// The selection can change under an open player (the command palette opens over it); the
|
||||||
// removal read the LIVE selection, so it took the video out of a list the viewer never touched.
|
// removal read the LIVE selection, so it took the video out of a list the viewer never touched.
|
||||||
const remove = vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
|
const remove = vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("V") as never);
|
||||||
const { rerender } = mount({ 1: [vid("V", 0)], 2: [vid("V", 0), vid("W", 0)] });
|
const { rerender } = mount({ 1: [vid("V", 0)], 2: [vid("V", 0), vid("W", 0)] });
|
||||||
await play("Video V");
|
await play("Video V");
|
||||||
await waitFor(() => expect(h.player).not.toBeNull());
|
await waitFor(() => expect(h.player).not.toBeNull());
|
||||||
@@ -419,7 +426,7 @@ describe("Playlists — removing a video from inside the player", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
await h.player!.onRemoveFromList!(vid("V", 0));
|
await h.player!.onRemoveFromList!(vid("V", 0));
|
||||||
});
|
});
|
||||||
expect(remove).toHaveBeenCalledWith(1, "V");
|
expect(remove).toHaveBeenCalledWith(1, ["V"]);
|
||||||
// …and the list on screen keeps its own copy of V: nothing was taken out of it.
|
// …and the list on screen keeps its own copy of V: nothing was taken out of it.
|
||||||
expect(screen.getByRole("button", { name: "card.play — Video V" })).toBeTruthy();
|
expect(screen.getByRole("button", { name: "card.play — Video V" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -427,7 +434,9 @@ describe("Playlists — removing a video from inside the player", () => {
|
|||||||
it("says so when the removal fails because the list is gone", async () => {
|
it("says so when the removal fails because the list is gone", async () => {
|
||||||
// `req` leaves 403/404 to the caller; the catch assumed a global dialog had already reported
|
// `req` leaves 403/404 to the caller; the catch assumed a global dialog had already reported
|
||||||
// it, so a confirmed removal failed in total silence behind the full-screen player.
|
// it, so a confirmed removal failed in total silence behind the full-screen player.
|
||||||
vi.spyOn(api, "removeFromPlaylist").mockRejectedValue(new HttpError(404, "Unknown playlist"));
|
vi.spyOn(api, "removeManyFromPlaylist").mockRejectedValue(
|
||||||
|
new HttpError(404, "Unknown playlist"),
|
||||||
|
);
|
||||||
mount({ 1: [vid("V", 0)] });
|
mount({ 1: [vid("V", 0)] });
|
||||||
await play("Video V");
|
await play("Video V");
|
||||||
await waitFor(() => expect(h.player).not.toBeNull());
|
await waitFor(() => expect(h.player).not.toBeNull());
|
||||||
@@ -458,7 +467,7 @@ describe("Playlists — removing a video from inside the player", () => {
|
|||||||
// The player steps to the next item after a removal and stores the last one's position into
|
// The player steps to the next item after a removal and stores the last one's position into
|
||||||
// every cached list. Landing before the refetch, that write handed the page a list still holding
|
// every cached list. Landing before the refetch, that write handed the page a list still holding
|
||||||
// the video, and the page — seeing a different set of ids — reset: the row came back.
|
// the video, and the page — seeing a different set of ids — reset: the row came back.
|
||||||
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
|
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("V") as never);
|
||||||
mount({ 1: [vid("V", 0), vid("W", 0)] });
|
mount({ 1: [vid("V", 0), vid("W", 0)] });
|
||||||
await play("Video V");
|
await play("Video V");
|
||||||
await waitFor(() => expect(h.player).not.toBeNull());
|
await waitFor(() => expect(h.player).not.toBeNull());
|
||||||
@@ -681,12 +690,12 @@ describe("Playlists — a change the server refused is taken back on screen", ()
|
|||||||
it("puts back a row whose removal failed, even when the resync fails too", async () => {
|
it("puts back a row whose removal failed, even when the resync fails too", async () => {
|
||||||
// A removal during an API restart: the DELETE fails, and so does the refetch meant to put the row
|
// A removal during an API restart: the DELETE fails, and so does the refetch meant to put the row
|
||||||
// back — the list kept showing it as removed while it was still in the playlist.
|
// back — the list kept showing it as removed while it was still in the playlist.
|
||||||
vi.spyOn(api, "removeFromPlaylist").mockRejectedValue(new TypeError("Failed to fetch"));
|
vi.spyOn(api, "removeManyFromPlaylist").mockRejectedValue(new TypeError("Failed to fetch"));
|
||||||
mount({ 1: [vid("V", 0), vid("W", 0)] });
|
mount({ 1: [vid("V", 0), vid("W", 0)] });
|
||||||
await screen.findByRole("button", { name: "card.play — Video V" });
|
await screen.findByRole("button", { name: "card.play — Video V" });
|
||||||
vi.mocked(api.playlist).mockRejectedValue(new TypeError("Failed to fetch"));
|
vi.mocked(api.playlist).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||||
await rowMenu("Video V", "playlists.removeItem");
|
await rowMenu("Video V", "playlists.removeItem");
|
||||||
await waitFor(() => expect(api.removeFromPlaylist).toHaveBeenCalled());
|
await waitFor(() => expect(api.removeManyFromPlaylist).toHaveBeenCalled());
|
||||||
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
||||||
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
|
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
|
||||||
expect(h.notify).toHaveBeenCalledWith(
|
expect(h.notify).toHaveBeenCalledWith(
|
||||||
@@ -700,10 +709,13 @@ describe("Playlists — a change the server refused is taken back on screen", ()
|
|||||||
describe("Playlists — removing a row, and undoing it", () => {
|
describe("Playlists — removing a row, and undoing it", () => {
|
||||||
// The server's side of a removal, so the refetch after it agrees with it.
|
// The server's side of a removal, so the refetch after it agrees with it.
|
||||||
const serverRemoves = (lists: Record<number, Video[]>) =>
|
const serverRemoves = (lists: Record<number, Video[]>) =>
|
||||||
vi.spyOn(api, "removeFromPlaylist").mockImplementation(async (id: number, videoId: string) => {
|
vi.spyOn(api, "removeManyFromPlaylist").mockImplementation((async (
|
||||||
lists[id] = lists[id]!.filter((v) => v.id !== videoId);
|
id: number,
|
||||||
return undefined as never;
|
videoIds: string[],
|
||||||
});
|
) => {
|
||||||
|
lists[id] = lists[id]!.filter((v) => !videoIds.includes(v.id));
|
||||||
|
return wentOut(...videoIds);
|
||||||
|
}) as never);
|
||||||
// The removal's toast — found by what it says, not by being the latest: a refetch after the
|
// The removal's toast — found by what it says, not by being the latest: a refetch after the
|
||||||
// removal may notify too.
|
// removal may notify too.
|
||||||
const lastToast = () =>
|
const lastToast = () =>
|
||||||
@@ -727,10 +739,10 @@ describe("Playlists — removing a row, and undoing it", () => {
|
|||||||
it("removes from the menu at once, and the toast's Undo puts it back at its place", async () => {
|
it("removes from the menu at once, and the toast's Undo puts it back at its place", async () => {
|
||||||
const lists = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
|
const lists = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
|
||||||
serverRemoves(lists);
|
serverRemoves(lists);
|
||||||
vi.spyOn(api, "addToPlaylist").mockResolvedValue(undefined as never);
|
vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true, added: 1 } as never);
|
||||||
mount(lists);
|
mount(lists);
|
||||||
await rowMenu("Video B", "playlists.removeItem");
|
await rowMenu("Video B", "playlists.removeItem");
|
||||||
await waitFor(() => expect(api.removeFromPlaylist).toHaveBeenCalledWith(1, "B"));
|
await waitFor(() => expect(api.removeManyFromPlaylist).toHaveBeenCalledWith(1, ["B"]));
|
||||||
expect(rowsOnScreen()).toEqual(["Video A", "Video C"]);
|
expect(rowsOnScreen()).toEqual(["Video A", "Video C"]);
|
||||||
// No confirm: the toast's Undo is the safety net.
|
// No confirm: the toast's Undo is the safety net.
|
||||||
expect(h.confirm).not.toHaveBeenCalled();
|
expect(h.confirm).not.toHaveBeenCalled();
|
||||||
@@ -740,13 +752,13 @@ describe("Playlists — removing a row, and undoing it", () => {
|
|||||||
expect(lastToast().action?.label).toEqual(msg("common.undo"));
|
expect(lastToast().action?.label).toEqual(msg("common.undo"));
|
||||||
act(() => lastToast().action!.onClick());
|
act(() => lastToast().action!.onClick());
|
||||||
// Index 1 of the STORED order: where it was, not the end of the list.
|
// Index 1 of the STORED order: where it was, not the end of the list.
|
||||||
await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledWith(1, "B", 1));
|
await waitFor(() => expect(api.restoreToPlaylist).toHaveBeenCalledWith(1, ["B"], [1]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("puts it back where it stood in the stored order, whatever the view on screen", async () => {
|
it("puts it back where it stood in the stored order, whatever the view on screen", async () => {
|
||||||
const lists = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
|
const lists = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
|
||||||
serverRemoves(lists);
|
serverRemoves(lists);
|
||||||
vi.spyOn(api, "addToPlaylist").mockResolvedValue(undefined as never);
|
vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true, added: 1 } as never);
|
||||||
mount(lists);
|
mount(lists);
|
||||||
await screen.findByRole("button", { name: "card.play — Video C" });
|
await screen.findByRole("button", { name: "card.play — Video C" });
|
||||||
// Sort the VIEW by title, descending: C, B, A on screen — the stored order is still A, B, C.
|
// Sort the VIEW by title, descending: C, B, A on screen — the stored order is still A, B, C.
|
||||||
@@ -756,7 +768,7 @@ describe("Playlists — removing a row, and undoing it", () => {
|
|||||||
await rowMenu("Video A", "playlists.removeItem");
|
await rowMenu("Video A", "playlists.removeItem");
|
||||||
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
|
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
|
||||||
act(() => lastToast().action!.onClick());
|
act(() => lastToast().action!.onClick());
|
||||||
await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledWith(1, "A", 0));
|
await waitFor(() => expect(api.restoreToPlaylist).toHaveBeenCalledWith(1, ["A"], [0]));
|
||||||
});
|
});
|
||||||
|
|
||||||
// The stored index drifts when the list changes before the Undo (a row added at
|
// The stored index drifts when the list changes before the Undo (a row added at
|
||||||
@@ -764,19 +776,45 @@ describe("Playlists — removing a row, and undoing it", () => {
|
|||||||
it("puts it back after its neighbour when the list changed before the Undo", async () => {
|
it("puts it back after its neighbour when the list changed before the Undo", async () => {
|
||||||
const lists = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
|
const lists = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
|
||||||
serverRemoves(lists);
|
serverRemoves(lists);
|
||||||
vi.spyOn(api, "addToPlaylist").mockResolvedValue(undefined as never);
|
vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true, added: 1 } as never);
|
||||||
mount(lists);
|
mount(lists);
|
||||||
await rowMenu("Video B", "playlists.removeItem");
|
await rowMenu("Video B", "playlists.removeItem");
|
||||||
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
|
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
|
||||||
lists[1] = [vid("X", 0), ...lists[1]!];
|
lists[1] = [vid("X", 0), ...lists[1]!];
|
||||||
act(() => lastToast().action!.onClick());
|
act(() => lastToast().action!.onClick());
|
||||||
await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledWith(1, "B", 2));
|
await waitFor(() => expect(api.restoreToPlaylist).toHaveBeenCalledWith(1, ["B"], [2]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so when the video could not come back, rather than nothing at all", async () => {
|
||||||
|
const lists = { 1: [vid("A", 0), vid("B", 0)] };
|
||||||
|
serverRemoves(lists);
|
||||||
|
vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true, added: 0 } as never);
|
||||||
|
mount(lists);
|
||||||
|
await rowMenu("Video A", "playlists.removeItem");
|
||||||
|
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
|
||||||
|
act(() => lastToast().action!.onClick());
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(h.notify).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ level: "warning", message: msg("playlists.undoNoneBack") }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes what Add to playlist knows about a removed video", async () => {
|
||||||
|
const lists = { 1: [vid("A", 0), vid("B", 0)] };
|
||||||
|
serverRemoves(lists);
|
||||||
|
mount(lists);
|
||||||
|
await screen.findByRole("button", { name: "card.play — Video A" });
|
||||||
|
qc.setQueryData(qk.playlistMembership("A"), { in: [1] });
|
||||||
|
await rowMenu("Video A", "playlists.removeItem");
|
||||||
|
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
|
||||||
|
expect(qc.getQueryState(qk.playlistMembership("A"))?.isInvalidated).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("says so when the Undo fails", async () => {
|
it("says so when the Undo fails", async () => {
|
||||||
const lists = { 1: [vid("A", 0), vid("B", 0)] };
|
const lists = { 1: [vid("A", 0), vid("B", 0)] };
|
||||||
serverRemoves(lists);
|
serverRemoves(lists);
|
||||||
vi.spyOn(api, "addToPlaylist").mockRejectedValue(new TypeError("Failed to fetch"));
|
vi.spyOn(api, "restoreToPlaylist").mockRejectedValue(new TypeError("Failed to fetch"));
|
||||||
mount(lists);
|
mount(lists);
|
||||||
await rowMenu("Video A", "playlists.removeItem");
|
await rowMenu("Video A", "playlists.removeItem");
|
||||||
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
|
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
|
||||||
@@ -797,20 +835,20 @@ describe("Playlists — removing a row, and undoing it", () => {
|
|||||||
pressKey("j");
|
pressKey("j");
|
||||||
expect(document.activeElement?.textContent).toContain("Video B");
|
expect(document.activeElement?.textContent).toContain("Video B");
|
||||||
pressKey("Delete");
|
pressKey("Delete");
|
||||||
await waitFor(() => expect(api.removeFromPlaylist).toHaveBeenCalledWith(1, "B"));
|
await waitFor(() => expect(api.removeManyFromPlaylist).toHaveBeenCalledWith(1, ["B"]));
|
||||||
expect(rowsOnScreen()).toEqual(["Video A", "Video C"]);
|
expect(rowsOnScreen()).toEqual(["Video A", "Video C"]);
|
||||||
await waitFor(() => expect(document.activeElement?.textContent).toContain("Video C"));
|
await waitFor(() => expect(document.activeElement?.textContent).toContain("Video C"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers no Delete, no Remove and no X on Watch later's rows", async () => {
|
it("offers no Delete, no Remove and no X on Watch later's rows", async () => {
|
||||||
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
|
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut() as never);
|
||||||
h.selectedId = WATCH_LATER;
|
h.selectedId = WATCH_LATER;
|
||||||
mount({ [WATCH_LATER]: [vid("A", 0), vid("B", 0)] });
|
mount({ [WATCH_LATER]: [vid("A", 0), vid("B", 0)] });
|
||||||
await keysLiveOn(await screen.findByRole("button", { name: "card.play — Video B" }));
|
await keysLiveOn(await screen.findByRole("button", { name: "card.play — Video B" }));
|
||||||
pressKey("j");
|
pressKey("j");
|
||||||
pressKey("Delete");
|
pressKey("Delete");
|
||||||
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
||||||
expect(api.removeFromPlaylist).not.toHaveBeenCalled();
|
expect(api.removeManyFromPlaylist).not.toHaveBeenCalled();
|
||||||
expect(screen.queryByRole("button", { name: /removeItemOf/ })).toBeNull();
|
expect(screen.queryByRole("button", { name: /removeItemOf/ })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1126,7 +1164,7 @@ describe("Playlists — selecting rows", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("drops a row removed from the list out of the selection", async () => {
|
it("drops a row removed from the list out of the selection", async () => {
|
||||||
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({ ok: true } as never);
|
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("A") as never);
|
||||||
mount(three);
|
mount(three);
|
||||||
fireEvent.click(await box("Video A"));
|
fireEvent.click(await box("Video A"));
|
||||||
fireEvent.click(await box("Video B"));
|
fireEvent.click(await box("Video B"));
|
||||||
@@ -1394,6 +1432,7 @@ describe("Playlists — dragging rows", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("under a sort or a filter", () => {
|
describe("under a sort or a filter", () => {
|
||||||
|
const keyStarts = () => dnd.props.sensors[1].options.keyboardCodes?.start;
|
||||||
const filter = async (value: string) => {
|
const filter = async (value: string) => {
|
||||||
fireEvent.click(screen.getAllByRole("button", { name: "datatable.filter" })[0]!);
|
fireEvent.click(screen.getAllByRole("button", { name: "datatable.filter" })[0]!);
|
||||||
fireEvent.change(await screen.findByPlaceholderText("playlists.colTitle"), {
|
fireEvent.change(await screen.findByPlaceholderText("playlists.colTitle"), {
|
||||||
@@ -1422,9 +1461,9 @@ describe("Playlists — dragging rows", () => {
|
|||||||
const order = vi.spyOn(api, "reorderPlaylist").mockResolvedValue({} as never);
|
const order = vi.spyOn(api, "reorderPlaylist").mockResolvedValue({} as never);
|
||||||
mount(abc);
|
mount(abc);
|
||||||
await aRow("A");
|
await aRow("A");
|
||||||
expect(dnd.props.sensors).toHaveLength(2);
|
expect(keyStarts()).toBeUndefined();
|
||||||
await filter("Video");
|
await filter("Video");
|
||||||
expect(dnd.props.sensors).toHaveLength(1);
|
expect(keyStarts()).toEqual([]);
|
||||||
overNothing();
|
overNothing();
|
||||||
pickUp("A");
|
pickUp("A");
|
||||||
expect(collide()).toEqual([]);
|
expect(collide()).toEqual([]);
|
||||||
@@ -1433,6 +1472,33 @@ describe("Playlists — dragging rows", () => {
|
|||||||
expect(order).not.toHaveBeenCalled();
|
expect(order).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a sort", "plsort=title:desc"],
|
||||||
|
["a grouping", "plgroup=1"],
|
||||||
|
])("holds under %s as under a filter", async (_view, query) => {
|
||||||
|
window.history.replaceState(null, "", `/?page=playlists&pl=1&${query}`);
|
||||||
|
const order = vi.spyOn(api, "reorderPlaylist").mockResolvedValue({} as never);
|
||||||
|
mount(abc);
|
||||||
|
await aRow("A");
|
||||||
|
expect(keyStarts()).toEqual([]);
|
||||||
|
await screen.findByRole("button", { name: "playlists.dragHandleToList:Video A" });
|
||||||
|
overNothing();
|
||||||
|
pickUp("A");
|
||||||
|
expect(collide()).toEqual([]);
|
||||||
|
dropOn("A");
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(order).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the same sensors, so a view turning on logs no changed dependency list", async () => {
|
||||||
|
const logged = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
mount(abc);
|
||||||
|
await aRow("A");
|
||||||
|
await filter("Video");
|
||||||
|
expect(logged.mock.calls.filter((c) => String(c[0]).includes("changed size"))).toEqual([]);
|
||||||
|
logged.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it("explains the grip with no other list to go to", async () => {
|
it("explains the grip with no other list to go to", async () => {
|
||||||
mount({ 1: abc[1]! });
|
mount({ 1: abc[1]! });
|
||||||
await aRow("A");
|
await aRow("A");
|
||||||
@@ -1442,9 +1508,56 @@ describe("Playlists — dragging rows", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("spends the drag's Esc on the drag: the ticks stay, and the next Esc clears them", async () => {
|
||||||
|
mount(abc);
|
||||||
|
await tick("Video A", "Video B");
|
||||||
|
overNothing();
|
||||||
|
pickUp("A");
|
||||||
|
const esc = () =>
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
esc();
|
||||||
|
act(() => {
|
||||||
|
dnd.props.onDragCancel();
|
||||||
|
});
|
||||||
|
expect(((await box("Video A")) as HTMLInputElement).checked).toBe(true);
|
||||||
|
expect(((await box("Video B")) as HTMLInputElement).checked).toBe(true);
|
||||||
|
esc();
|
||||||
|
expect(((await box("Video A")) as HTMLInputElement).checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the list where it is while the drag looks for a list in the rail", async () => {
|
||||||
|
mount(abc);
|
||||||
|
await aRow("A");
|
||||||
|
overRail(2);
|
||||||
|
pickUp("A");
|
||||||
|
expect(dnd.props.autoScroll.canScroll()).toBe(false);
|
||||||
|
act(() => {
|
||||||
|
dnd.props.onDragCancel();
|
||||||
|
});
|
||||||
|
overNothing();
|
||||||
|
pickUp("A");
|
||||||
|
expect(dnd.props.autoScroll.canScroll()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
describe("what a screen reader hears", () => {
|
describe("what a screen reader hears", () => {
|
||||||
const say = () => dnd.props.accessibility.announcements;
|
const say = () => dnd.props.accessibility.announcements;
|
||||||
|
|
||||||
|
it("counts them when many stay put, whichever way the drag ends", async () => {
|
||||||
|
mount(abc);
|
||||||
|
await tick("Video A", "Video B");
|
||||||
|
overNothing();
|
||||||
|
pickUp("A");
|
||||||
|
expect(say().onDragCancel({ active: { id: "A" } })).toBe("playlists.dragCancelledMany");
|
||||||
|
dropOn("A");
|
||||||
|
expect(say().onDragEnd({ active: { id: "A" }, over: null })).toBe(
|
||||||
|
"playlists.dragCancelledMany",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("names how many are picked up, and the list under the drag", async () => {
|
it("names how many are picked up, and the list under the drag", async () => {
|
||||||
mount(abc);
|
mount(abc);
|
||||||
await tick("Video A", "Video B");
|
await tick("Video A", "Video B");
|
||||||
@@ -1509,6 +1622,14 @@ describe("Playlists — acting on the selected rows", () => {
|
|||||||
action?: { onClick: () => void };
|
action?: { onClick: () => void };
|
||||||
};
|
};
|
||||||
const button = (name: string) => screen.findByRole("button", { name });
|
const button = (name: string) => screen.findByRole("button", { name });
|
||||||
|
const menuRow = (label: string) =>
|
||||||
|
waitFor(() => {
|
||||||
|
const row = screen
|
||||||
|
.getAllByRole("menuitem", { hidden: true })
|
||||||
|
.find((b) => b.textContent === label);
|
||||||
|
if (!row) throw new Error(`no menu row "${label}"`);
|
||||||
|
return row;
|
||||||
|
});
|
||||||
const moved = (ids: string[], already_there: string[] = []) => ({
|
const moved = (ids: string[], already_there: string[] = []) => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
from_playlist_id: 1,
|
from_playlist_id: 1,
|
||||||
@@ -1527,7 +1648,7 @@ describe("Playlists — acting on the selected rows", () => {
|
|||||||
expect(screen.queryByText("playlists.selectedCount")).toBeNull();
|
expect(screen.queryByText("playlists.selectedCount")).toBeNull();
|
||||||
expect(screen.getByRole("checkbox", { name: "playlists.groupByChannel" })).toBeTruthy();
|
expect(screen.getByRole("checkbox", { name: "playlists.groupByChannel" })).toBeTruthy();
|
||||||
await tick("Video A");
|
await tick("Video A");
|
||||||
expect(await screen.findByText("playlists.selectedCount")).toBeTruthy();
|
expect(await screen.findAllByText("playlists.selectedCount")).toBeTruthy();
|
||||||
expect(screen.queryByRole("checkbox", { name: "playlists.groupByChannel" })).toBeNull();
|
expect(screen.queryByRole("checkbox", { name: "playlists.groupByChannel" })).toBeNull();
|
||||||
fireEvent.click(await button("playlists.clearSelection"));
|
fireEvent.click(await button("playlists.clearSelection"));
|
||||||
expect(screen.queryByText("playlists.selectedCount")).toBeNull();
|
expect(screen.queryByText("playlists.selectedCount")).toBeNull();
|
||||||
@@ -1659,9 +1780,11 @@ describe("Playlists — acting on the selected rows", () => {
|
|||||||
const lists = { ...abc };
|
const lists = { ...abc };
|
||||||
const remove = vi.spyOn(api, "removeManyFromPlaylist").mockImplementation((async () => {
|
const remove = vi.spyOn(api, "removeManyFromPlaylist").mockImplementation((async () => {
|
||||||
lists[1] = lists[1]!.filter((v) => v.id === "B");
|
lists[1] = lists[1]!.filter((v) => v.id === "B");
|
||||||
return { ok: true, playlist_id: 1, removed: 2 };
|
return wentOut("A", "C");
|
||||||
}) as never);
|
}) as never);
|
||||||
const restore = vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true } as never);
|
const restore = vi
|
||||||
|
.spyOn(api, "restoreToPlaylist")
|
||||||
|
.mockResolvedValue({ ok: true, added: 2 } as never);
|
||||||
mount(lists);
|
mount(lists);
|
||||||
await tick("Video A", "Video C");
|
await tick("Video A", "Video C");
|
||||||
fireEvent.click(await button("playlists.removeSelected"));
|
fireEvent.click(await button("playlists.removeSelected"));
|
||||||
@@ -1686,11 +1809,7 @@ describe("Playlists — acting on the selected rows", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("names the one video when one was removed", async () => {
|
it("names the one video when one was removed", async () => {
|
||||||
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({
|
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("B") as never);
|
||||||
ok: true,
|
|
||||||
playlist_id: 1,
|
|
||||||
removed: 1,
|
|
||||||
} as never);
|
|
||||||
mount(abc);
|
mount(abc);
|
||||||
await tick("Video B");
|
await tick("Video B");
|
||||||
fireEvent.click(await button("playlists.removeSelected"));
|
fireEvent.click(await button("playlists.removeSelected"));
|
||||||
@@ -1701,25 +1820,22 @@ describe("Playlists — acting on the selected rows", () => {
|
|||||||
it("has Delete take the selection when there is one, with the focus on a box", async () => {
|
it("has Delete take the selection when there is one, with the focus on a box", async () => {
|
||||||
const remove = vi
|
const remove = vi
|
||||||
.spyOn(api, "removeManyFromPlaylist")
|
.spyOn(api, "removeManyFromPlaylist")
|
||||||
.mockResolvedValue({ ok: true, playlist_id: 1, removed: 2 } as never);
|
.mockResolvedValue(wentOut("A", "B") as never);
|
||||||
const single = vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({ ok: true } as never);
|
|
||||||
mount(abc);
|
mount(abc);
|
||||||
await tick("Video A", "Video B");
|
await tick("Video A", "Video B");
|
||||||
(await box("Video B")).focus();
|
(await box("Video B")).focus();
|
||||||
await act(async () => press("Delete"));
|
await act(async () => press("Delete"));
|
||||||
await waitFor(() => expect(remove).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(remove).toHaveBeenCalledTimes(1));
|
||||||
expect(single).not.toHaveBeenCalled();
|
expect([...remove.mock.calls[0]![1]].sort()).toEqual(["A", "B"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has Delete take the walked row when nothing is selected", async () => {
|
it("has Delete take the walked row when nothing is selected", async () => {
|
||||||
const remove = vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({} as never);
|
const remove = vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("A") as never);
|
||||||
const single = vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({ ok: true } as never);
|
|
||||||
mount(abc);
|
mount(abc);
|
||||||
await box("Video A");
|
await box("Video A");
|
||||||
press("j");
|
press("j");
|
||||||
await act(async () => press("Delete"));
|
await act(async () => press("Delete"));
|
||||||
await waitFor(() => expect(single).toHaveBeenCalledWith(1, "A"));
|
await waitFor(() => expect(remove).toHaveBeenCalledWith(1, ["A"]));
|
||||||
expect(remove).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a row's own menu on that row, and leaves the selection alone", async () => {
|
it("keeps a row's own menu on that row, and leaves the selection alone", async () => {
|
||||||
@@ -1751,9 +1867,7 @@ describe("Playlists — acting on the selected rows", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("acts on the rows shown, says how many a filter hides, and keeps the view's reset", async () => {
|
it("acts on the rows shown, says how many a filter hides, and keeps the view's reset", async () => {
|
||||||
const remove = vi
|
const remove = vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("B") as never);
|
||||||
.spyOn(api, "removeManyFromPlaylist")
|
|
||||||
.mockResolvedValue({ ok: true, playlist_id: 1, removed: 1 } as never);
|
|
||||||
mount(abc);
|
mount(abc);
|
||||||
await tick("Video A", "Video B");
|
await tick("Video A", "Video B");
|
||||||
fireEvent.click(screen.getAllByRole("button", { name: "datatable.filter" })[0]!);
|
fireEvent.click(screen.getAllByRole("button", { name: "datatable.filter" })[0]!);
|
||||||
@@ -1767,11 +1881,7 @@ describe("Playlists — acting on the selected rows", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("forgets the ticks a filter hid once it acted, too", async () => {
|
it("forgets the ticks a filter hid once it acted, too", async () => {
|
||||||
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({
|
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("B") as never);
|
||||||
ok: true,
|
|
||||||
playlist_id: 1,
|
|
||||||
removed: 1,
|
|
||||||
} as never);
|
|
||||||
mount(abc);
|
mount(abc);
|
||||||
await tick("Video A", "Video B");
|
await tick("Video A", "Video B");
|
||||||
fireEvent.click(screen.getAllByRole("button", { name: "datatable.filter" })[0]!);
|
fireEvent.click(screen.getAllByRole("button", { name: "datatable.filter" })[0]!);
|
||||||
@@ -1808,7 +1918,7 @@ describe("Playlists — acting on the selected rows", () => {
|
|||||||
const count = (await screen.findByText(/playlists\.itemCount/)).parentElement!;
|
const count = (await screen.findByText(/playlists\.itemCount/)).parentElement!;
|
||||||
expect(count.classList.contains("invisible")).toBe(false);
|
expect(count.classList.contains("invisible")).toBe(false);
|
||||||
await tick("Video A");
|
await tick("Video A");
|
||||||
const band = (await screen.findByText("playlists.selectedCount")).closest("[role=status]")!;
|
const band = await button("playlists.clearSelection");
|
||||||
expect(count.classList.contains("invisible")).toBe(true);
|
expect(count.classList.contains("invisible")).toBe(true);
|
||||||
expect(count.getAttribute("aria-hidden")).toBe("true");
|
expect(count.getAttribute("aria-hidden")).toBe("true");
|
||||||
expect(count.parentElement).toBe(band.parentElement!.parentElement!.parentElement);
|
expect(count.parentElement).toBe(band.parentElement!.parentElement!.parentElement);
|
||||||
@@ -1878,11 +1988,7 @@ describe("Playlists — acting on the selected rows", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("hands the focus to the list's menu when the band's action emptied the list", async () => {
|
it("hands the focus to the list's menu when the band's action emptied the list", async () => {
|
||||||
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({
|
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("A") as never);
|
||||||
ok: true,
|
|
||||||
playlist_id: 1,
|
|
||||||
removed: 1,
|
|
||||||
} as never);
|
|
||||||
mount({ 1: [vid("A", 0)] });
|
mount({ 1: [vid("A", 0)] });
|
||||||
await tick("Video A");
|
await tick("Video A");
|
||||||
const remove = await button("playlists.removeSelected");
|
const remove = await button("playlists.removeSelected");
|
||||||
@@ -1892,6 +1998,237 @@ describe("Playlists — acting on the selected rows", () => {
|
|||||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: "playlists.railMenu" }));
|
expect(document.activeElement).toBe(screen.getByRole("button", { name: "playlists.railMenu" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["the header's box", () => screen.getByRole("checkbox", { name: "playlists.selectAllShown" })],
|
||||||
|
["a row's box", () => screen.getByRole("checkbox", { name: "playlists.selectRow:Video A" })],
|
||||||
|
])("hands the focus to the list's menu when Delete on %s emptied the list", async (_n, held) => {
|
||||||
|
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("A") as never);
|
||||||
|
mount({ 1: [vid("A", 0)] });
|
||||||
|
await tick("Video A");
|
||||||
|
held().focus();
|
||||||
|
await act(async () => press("Delete"));
|
||||||
|
await waitFor(() => expect(toast().action).toBeTruthy());
|
||||||
|
expect(document.activeElement).toBe(screen.getByRole("button", { name: "playlists.railMenu" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("goes on to the next row left, past the rows taken from above the focus", async () => {
|
||||||
|
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("A", "B", "C") as never);
|
||||||
|
mount({ 1: ["A", "B", "C", "D", "E"].map((id) => vid(id, 0)) });
|
||||||
|
await tick("Video A", "Video B", "Video C");
|
||||||
|
(await box("Video C")).focus();
|
||||||
|
await act(async () => press("Delete"));
|
||||||
|
await waitFor(() => expect(document.activeElement?.textContent).toContain("Video D"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes no row at all on Delete when a filter hides every tick, and says why", async () => {
|
||||||
|
const remove = vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut() as never);
|
||||||
|
mount(abc);
|
||||||
|
await tick("Video A");
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: "datatable.filter" })[0]!);
|
||||||
|
fireEvent.change(await screen.findByPlaceholderText("playlists.colTitle"), {
|
||||||
|
target: { value: "Video B" },
|
||||||
|
});
|
||||||
|
await screen.findByText("playlists.selectedHidden");
|
||||||
|
(await screen.findByRole("button", { name: "card.play — Video B" })).focus();
|
||||||
|
await act(async () => press("Delete"));
|
||||||
|
expect(toast().message).toEqual(msg("playlists.removeAllHidden"));
|
||||||
|
expect(remove).not.toHaveBeenCalled();
|
||||||
|
expect(rowsOnScreen()).toEqual(["Video B"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["the band's", true],
|
||||||
|
["the meta line's", false],
|
||||||
|
])("hands the focus on when %s Reset view goes with the view", async (_n, selecting) => {
|
||||||
|
window.history.replaceState(null, "", "/?page=playlists&pl=1&plsort=title:desc");
|
||||||
|
mount(abc);
|
||||||
|
if (selecting) await tick("Video A");
|
||||||
|
const reset = await button("playlists.clearView");
|
||||||
|
reset.focus();
|
||||||
|
fireEvent.click(reset);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole("button", { name: "playlists.clearView" })).toBeNull(),
|
||||||
|
);
|
||||||
|
expect(document.activeElement).toBe(
|
||||||
|
screen.getByRole("checkbox", { name: "playlists.selectAllShown" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The answer comes later than the render, as a server's does: until then the row has left the list,
|
||||||
|
// and its tick with it.
|
||||||
|
it.each([
|
||||||
|
["move", "moveManyToPlaylist", "playlists.moveToRecent"],
|
||||||
|
["removal", "removeManyFromPlaylist", "playlists.removeItem"],
|
||||||
|
] as const)("ticks a row again when its own menu's %s failed", async (_n, call, item) => {
|
||||||
|
let fail: (e: unknown) => void = () => {};
|
||||||
|
vi.spyOn(api, call).mockImplementation(
|
||||||
|
(() =>
|
||||||
|
new Promise((_ok, no) => {
|
||||||
|
fail = no;
|
||||||
|
})) as never,
|
||||||
|
);
|
||||||
|
mount(abc, { lastMovePlaylistId: 2 });
|
||||||
|
await tick("Video A", "Video B");
|
||||||
|
await rowMenu("Video A", item);
|
||||||
|
await waitFor(() => expect(rowsOnScreen()).toEqual(["Video B", "Video C"]));
|
||||||
|
await act(async () => fail(new HttpError(500, "boom")));
|
||||||
|
await waitFor(() => expect(rowsOnScreen()).toEqual(["Video A", "Video B", "Video C"]));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(ticked()).toEqual(["playlists.selectRow:Video A", "playlists.selectRow:Video B"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ticks nothing on the list now on screen when a move from another one fails", async () => {
|
||||||
|
let fail: (e: unknown) => void = () => {};
|
||||||
|
const move = vi.spyOn(api, "moveManyToPlaylist").mockImplementation(
|
||||||
|
(() =>
|
||||||
|
new Promise((_ok, no) => {
|
||||||
|
fail = no;
|
||||||
|
})) as never,
|
||||||
|
);
|
||||||
|
const { rerender } = mount(
|
||||||
|
{ 1: [vid("A", 0), vid("B", 0)], 2: [vid("A", 0)], 3: [] },
|
||||||
|
{ lastMovePlaylistId: 3 },
|
||||||
|
);
|
||||||
|
await tick("Video A");
|
||||||
|
fireEvent.click(await button("playlists.moveToRecent"));
|
||||||
|
await waitFor(() => expect(move).toHaveBeenCalled());
|
||||||
|
h.selectedId = 2;
|
||||||
|
rerender();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole("button", { name: "card.play — Video B" })).toBeNull(),
|
||||||
|
);
|
||||||
|
await box("Video A");
|
||||||
|
await act(async () => fail(new HttpError(500, "boom")));
|
||||||
|
await waitFor(() => expect(toast().level).toBe("error"));
|
||||||
|
expect(((await box("Video A")) as HTMLInputElement).checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says out loud the count, and that the selection went", async () => {
|
||||||
|
mount(abc);
|
||||||
|
await box("Video A");
|
||||||
|
const status = () => document.querySelector(".sr-only[role=status]")!.textContent;
|
||||||
|
expect(status()).toBe("");
|
||||||
|
await tick("Video A");
|
||||||
|
await waitFor(() => expect(status()).toBe("playlists.selectedCount"));
|
||||||
|
await act(async () => press("Escape"));
|
||||||
|
await waitFor(() => expect(status()).toBe("playlists.selectionCleared"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves to the list picked in the band's menu, not the last one", async () => {
|
||||||
|
const move = vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A"]) as never);
|
||||||
|
mount({ ...abc, 3: [] }, { lastMovePlaylistId: 2 });
|
||||||
|
await tick("Video A");
|
||||||
|
fireEvent.click(await button("playlists.moveSelected"));
|
||||||
|
fireEvent.click(await menuRow("List 3"));
|
||||||
|
await waitFor(() => expect(move).toHaveBeenCalledWith(1, ["A"], 3));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves to the list picked in a row's own menu", async () => {
|
||||||
|
const move = vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A"]) as never);
|
||||||
|
mount({ ...abc, 3: [] }, { lastMovePlaylistId: 2 });
|
||||||
|
await box("Video A");
|
||||||
|
await rowMenu("Video A", "playlists.moveTo");
|
||||||
|
fireEvent.click(await menuRow("List 3"));
|
||||||
|
await waitFor(() => expect(move).toHaveBeenCalledWith(1, ["A"], 3));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the target's cached list stale too, not only the one on screen", async () => {
|
||||||
|
vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A"]) as never);
|
||||||
|
mount(abc, { lastMovePlaylistId: 2 });
|
||||||
|
await tick("Video A");
|
||||||
|
qc.setQueryData(qk.playlist(2), list(2, []));
|
||||||
|
fireEvent.click(await button("playlists.moveToRecent"));
|
||||||
|
await waitFor(() => expect(toast().action).toBeTruthy());
|
||||||
|
expect(qc.getQueryState(qk.playlist(2))?.isInvalidated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so when none of them was in the list any more, with no Undo to offer: a removal", async () => {
|
||||||
|
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut() as never);
|
||||||
|
mount(abc);
|
||||||
|
await tick("Video A", "Video B");
|
||||||
|
fireEvent.click(await button("playlists.removeSelected"));
|
||||||
|
await waitFor(() => expect(toast().message).toEqual(msg("playlists.removeNoneLeft")));
|
||||||
|
expect(toast().action).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts what really went, and its Undo brings back only those", async () => {
|
||||||
|
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("A", "C") as never);
|
||||||
|
const restore = vi
|
||||||
|
.spyOn(api, "restoreToPlaylist")
|
||||||
|
.mockResolvedValue({ ok: true, added: 2 } as never);
|
||||||
|
mount(abc);
|
||||||
|
await tick("Video A", "Video B", "Video C");
|
||||||
|
fireEvent.click(await button("playlists.removeSelected"));
|
||||||
|
await waitFor(() => expect(toast().action).toBeTruthy());
|
||||||
|
expect(toast().message).toEqual(msg("playlists.removedManyToast", { count: 2 }));
|
||||||
|
vi.spyOn(api, "playlist").mockResolvedValue(list(1, []) as never);
|
||||||
|
toast().action!.onClick();
|
||||||
|
await waitFor(() => expect(restore).toHaveBeenCalledWith(1, ["A", "C"], [0, 1]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[0, msg("playlists.undoNoneBack")],
|
||||||
|
[1, msg("playlists.undoSomeBack", { count: 1 })],
|
||||||
|
])("says what came back when a move's Undo brought %i of 2", async (added, said) => {
|
||||||
|
vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A", "B"]) as never);
|
||||||
|
vi.spyOn(api, "undoPlaylistMove").mockResolvedValue({ ok: true, added } as never);
|
||||||
|
mount(abc, { lastMovePlaylistId: 2 });
|
||||||
|
await tick("Video A", "Video B");
|
||||||
|
fireEvent.click(await button("playlists.moveToRecent"));
|
||||||
|
await waitFor(() => expect(toast().action).toBeTruthy());
|
||||||
|
vi.spyOn(api, "playlist").mockResolvedValue(list(1, [vid("C", 0)]) as never);
|
||||||
|
toast().action!.onClick();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(h.notify).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ level: "warning", message: said }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing more when a move's Undo brought them all", async () => {
|
||||||
|
vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A"]) as never);
|
||||||
|
const undo = vi
|
||||||
|
.spyOn(api, "undoPlaylistMove")
|
||||||
|
.mockResolvedValue({ ok: true, added: 1 } as never);
|
||||||
|
mount(abc, { lastMovePlaylistId: 2 });
|
||||||
|
await tick("Video A");
|
||||||
|
fireEvent.click(await button("playlists.moveToRecent"));
|
||||||
|
await waitFor(() => expect(toast().action).toBeTruthy());
|
||||||
|
const toasts = h.notify.mock.calls.length;
|
||||||
|
vi.spyOn(api, "playlist").mockResolvedValue(list(1, [vid("B", 0), vid("C", 0)]) as never);
|
||||||
|
toast().action!.onClick();
|
||||||
|
await waitFor(() => expect(undo).toHaveBeenCalled());
|
||||||
|
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
||||||
|
expect(h.notify.mock.calls.length).toBe(toasts);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call a list that failed to load no other list", async () => {
|
||||||
|
mount(
|
||||||
|
{ 1: [vid("A", 0)] },
|
||||||
|
{},
|
||||||
|
{ rail: async () => Promise.reject(new HttpError(500, "boom")) },
|
||||||
|
);
|
||||||
|
await tick("Video A");
|
||||||
|
fireEvent.click(await button("playlists.moveSelected"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("menuitem", { hidden: true }).textContent).toContain(
|
||||||
|
"playlists.moveTargetsFailed",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call a list still loading no other list", async () => {
|
||||||
|
mount({ 1: [vid("A", 0)] }, {}, { rail: () => new Promise(() => {}) });
|
||||||
|
await tick("Video A");
|
||||||
|
fireEvent.click(await button("playlists.moveSelected"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("menuitem", { hidden: true }).textContent).toContain(
|
||||||
|
"playlists.moveTargetsLoading",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves the focus alone when the band goes and it was elsewhere", async () => {
|
it("leaves the focus alone when the band goes and it was elsewhere", async () => {
|
||||||
mount(abc);
|
mount(abc);
|
||||||
await tick("Video A");
|
await tick("Video A");
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import {
|
||||||
import { createPortal } from "react-dom";
|
lazy,
|
||||||
|
Suspense,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type MouseEvent,
|
||||||
|
} from "react";
|
||||||
import { useLatest, useStableCallback } from "../../lib/useLatest";
|
import { useLatest, useStableCallback } from "../../lib/useLatest";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { hasResumePoint } from "../../lib/api/feed";
|
import { hasResumePoint } from "../../lib/api/feed";
|
||||||
import {
|
import {
|
||||||
addPlaylistItem,
|
|
||||||
movePlaylistItems,
|
movePlaylistItems,
|
||||||
removePlaylistItem,
|
|
||||||
removePlaylistItems,
|
removePlaylistItems,
|
||||||
restorePlaylistItems,
|
restorePlaylistItems,
|
||||||
undoPlaylistMove,
|
undoPlaylistMove,
|
||||||
@@ -21,6 +27,7 @@ import {
|
|||||||
PointerSensor,
|
PointerSensor,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
|
KeyboardCode,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
type CollisionDetection,
|
type CollisionDetection,
|
||||||
@@ -68,6 +75,7 @@ import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
|||||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||||
import { useComfortPin, pinPlaylist, pinVideo } from "../../features/comfort/comfortPin";
|
import { useComfortPin, pinPlaylist, pinVideo } from "../../features/comfort/comfortPin";
|
||||||
import { ContextMenu } from "../../components/ui/ContextMenu";
|
import { ContextMenu } from "../../components/ui/ContextMenu";
|
||||||
|
import Overlay from "../../components/ui/Overlay";
|
||||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||||
import { usePageMenu } from "../../components/ui/pageMenuSlot";
|
import { usePageMenu } from "../../components/ui/pageMenuSlot";
|
||||||
import { menuGroups } from "../../components/ui/menuGroups";
|
import { menuGroups } from "../../components/ui/menuGroups";
|
||||||
@@ -136,6 +144,15 @@ const moveTargetIcon = (pl: Playlist) =>
|
|||||||
<ListPlus className="w-4 h-4" />
|
<ListPlus className="w-4 h-4" />
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Under a view a key would pick the row up with nowhere to put it: only the rail is left to go to.
|
||||||
|
* No key starts it, rather than no such sensor: dnd-kit takes the sensors as an effect's dependency
|
||||||
|
* list, whose size must not change. */
|
||||||
|
const NO_KEY_STARTS = {
|
||||||
|
start: [],
|
||||||
|
cancel: [KeyboardCode.Esc],
|
||||||
|
end: [KeyboardCode.Space, KeyboardCode.Enter, KeyboardCode.Tab],
|
||||||
|
};
|
||||||
|
|
||||||
/** A pointer drag lands where the pointer is. dnd-kit measures from the drag image, whose small
|
/** A pointer drag lands where the pointer is. dnd-kit measures from the drag image, whose small
|
||||||
* box has its centre elsewhere: on a tablet's taller rows a drop reached the row before. */
|
* box has its centre elsewhere: on a tablet's taller rows a drop reached the row before. */
|
||||||
function pointerFirst(args: Parameters<CollisionDetection>[0]): Parameters<CollisionDetection>[0] {
|
function pointerFirst(args: Parameters<CollisionDetection>[0]): Parameters<CollisionDetection>[0] {
|
||||||
@@ -421,11 +438,13 @@ function PlaylistDetail() {
|
|||||||
// sort, filters and grouping. All three are this component's own state, which is what makes a
|
// sort, filters and grouping. All three are this component's own state, which is what makes a
|
||||||
// render-phase write legal here.
|
// render-phase write legal here.
|
||||||
const [viewFor, setViewFor] = useState(selectedId);
|
const [viewFor, setViewFor] = useState(selectedId);
|
||||||
|
const [bandWasUp, setBandWasUp] = useState(false);
|
||||||
if (viewFor !== selectedId) {
|
if (viewFor !== selectedId) {
|
||||||
setViewFor(selectedId);
|
setViewFor(selectedId);
|
||||||
setViewSort(STORED_SORT);
|
setViewSort(STORED_SORT);
|
||||||
setFilters({});
|
setFilters({});
|
||||||
setGroupBy(false);
|
setGroupBy(false);
|
||||||
|
setBandWasUp(false);
|
||||||
}
|
}
|
||||||
useUrlMirror(
|
useUrlMirror(
|
||||||
playlistViewParamUpdates(selectedId != null ? { sort: viewSort, filters, groupBy } : null),
|
playlistViewParamUpdates(selectedId != null ? { sort: viewSort, filters, groupBy } : null),
|
||||||
@@ -515,6 +534,7 @@ function PlaylistDetail() {
|
|||||||
);
|
);
|
||||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||||
const bandRef = useRef<HTMLDivElement>(null);
|
const bandRef = useRef<HTMLDivElement>(null);
|
||||||
|
const tableRef = useRef<HTMLTableElement>(null);
|
||||||
// The band's own "Move to" list, the row menu's with no row behind it.
|
// The band's own "Move to" list, the row menu's with no row behind it.
|
||||||
const moveMenu = useContextMenu<"selection">();
|
const moveMenu = useContextMenu<"selection">();
|
||||||
const tableColumns = canEdit
|
const tableColumns = canEdit
|
||||||
@@ -541,6 +561,18 @@ function PlaylistDetail() {
|
|||||||
const viewActive = !isStoredSort(viewSort) || groupBy || filterActive;
|
const viewActive = !isStoredSort(viewSort) || groupBy || filterActive;
|
||||||
// While anything is selected, shown or not: a tick only a filter hides still needs its way out.
|
// While anything is selected, shown or not: a tick only a filter hides still needs its way out.
|
||||||
const bandUp = canEdit && selection.shownSelected.length + selection.hiddenCount > 0;
|
const bandUp = canEdit && selection.shownSelected.length + selection.hiddenCount > 0;
|
||||||
|
if (bandUp && !bandWasUp) setBandWasUp(true);
|
||||||
|
// Out loud from a region that outlives the band: its news includes the band going.
|
||||||
|
const selectionStatus = bandUp
|
||||||
|
? [
|
||||||
|
t("playlists.selectedCount", { count: selection.shownSelected.length }),
|
||||||
|
...(selection.hiddenCount > 0
|
||||||
|
? [t("playlists.selectedHidden", { count: selection.hiddenCount })]
|
||||||
|
: []),
|
||||||
|
].join(" · ")
|
||||||
|
: bandWasUp
|
||||||
|
? t("playlists.selectionCleared")
|
||||||
|
: "";
|
||||||
// Saving a FILTERED view would drop every hidden item from the playlist, so the button only
|
// Saving a FILTERED view would drop every hidden item from the playlist, so the button only
|
||||||
// offers itself when the view is a pure reordering of the whole list.
|
// offers itself when the view is a pure reordering of the whole list.
|
||||||
const canSaveOrder = canEdit && !filterActive && !sameOrder(shown, items);
|
const canSaveOrder = canEdit && !filterActive && !sameOrder(shown, items);
|
||||||
@@ -557,6 +589,13 @@ function PlaylistDetail() {
|
|||||||
setFilters({});
|
setFilters({});
|
||||||
setGroupBy(false);
|
setGroupBy(false);
|
||||||
}
|
}
|
||||||
|
// From a button that goes with the view: a focus on it would fall to <body>, and the next Tab start
|
||||||
|
// over at the top of the page.
|
||||||
|
const resetView = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
if (document.activeElement === e.currentTarget)
|
||||||
|
(selectAllRef.current ?? renameTarget.current)?.focus();
|
||||||
|
clearView();
|
||||||
|
};
|
||||||
function saveOrder() {
|
function saveOrder() {
|
||||||
order.set(shown); // onApply persists it
|
order.set(shown); // onApply persists it
|
||||||
clearView();
|
clearView();
|
||||||
@@ -565,10 +604,11 @@ function PlaylistDetail() {
|
|||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||||
// The grip was focusable and called itself "reorder", but only a pointer could move it.
|
// The grip was focusable and called itself "reorder", but only a pointer could move it.
|
||||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
keyboardCodes: viewActive ? NO_KEY_STARTS : undefined,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
// Under a view a key would pick the row up with nowhere to put it: only the rail is left to go to.
|
|
||||||
const pointerSensorOnly = sensors.slice(0, 1);
|
|
||||||
|
|
||||||
// Named and placed from `shownRef`, read at announcement time (see ui/sortableA11y).
|
// Named and placed from `shownRef`, read at announcement time (see ui/sortableA11y).
|
||||||
const dndA11y = useSortableA11y((id) => {
|
const dndA11y = useSortableA11y((id) => {
|
||||||
@@ -582,11 +622,6 @@ function PlaylistDetail() {
|
|||||||
|
|
||||||
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
|
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
|
||||||
|
|
||||||
function refreshAll(listId: number | null = selectedId) {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
|
||||||
qc.invalidateQueries({ queryKey: qk.playlist(listId) });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The rows on the drag image. A selected row carries the others; a key moves only its own row. */
|
/** The rows on the drag image. A selected row carries the others; a key moves only its own row. */
|
||||||
const [carried, setCarried] = useState<readonly string[] | null>(null);
|
const [carried, setCarried] = useState<readonly string[] | null>(null);
|
||||||
const carriedRef = useRef<readonly string[]>([]);
|
const carriedRef = useRef<readonly string[]>([]);
|
||||||
@@ -643,82 +678,10 @@ function PlaylistDetail() {
|
|||||||
|
|
||||||
/** Takes a row out at once and says so in a toast whose Undo puts it back where it was (the
|
/** Takes a row out at once and says so in a toast whose Undo puts it back where it was (the
|
||||||
* X column became the Delete key + this toast; no confirm, because nothing is lost).
|
* X column became the Delete key + this toast; no confirm, because nothing is lost).
|
||||||
* @returns whether the item was actually removed — the player needs to know, because it must
|
* @returns whether the write went through — the player needs to know, because it must step to the
|
||||||
* step to the next item (or close) only when the removal really happened. */
|
* next item (or close) only then. */
|
||||||
async function removeItem(video: Video, listIdArg?: number | null): Promise<boolean> {
|
function removeItem(video: Video, listId?: number | null): Promise<boolean> {
|
||||||
// Defaulted here, not as `listId = selectedRef.current` in the signature: the React Compiler
|
return removeRows([video], tickOf(video), listId);
|
||||||
// cannot reorder a ref read in a default parameter and skipped the component for it.
|
|
||||||
const listId = listIdArg === undefined ? selectedRef.current : listIdArg;
|
|
||||||
if (listId == null) return false;
|
|
||||||
// Only the list ON SCREEN has rows to take the item out of optimistically — the open player can
|
|
||||||
// be removing from the list it was launched from while another one is selected.
|
|
||||||
if (listId === selectedRef.current) {
|
|
||||||
// A removal isn't itself on the reorder undo stack, but it no longer THROWS AWAY that history:
|
|
||||||
// rebasing drops the item from every snapshot, so the stack stays valid for what's left.
|
|
||||||
const next = order.value.filter((v) => v.id !== video.id);
|
|
||||||
order.rebase((list) => list.filter((v) => v.id !== video.id));
|
|
||||||
lastSetRef.current = `${listId}:${idsKey(next)}`;
|
|
||||||
}
|
|
||||||
// …and out of the CACHED list too, not just the rows. Anything that rewrites that cache entry
|
|
||||||
// before the refetch lands handed the membership guard a list that still held the video — the
|
|
||||||
// player's own progress checkpoint does exactly that when a removal steps it to the next item —
|
|
||||||
// so the guard reset the order: the removed row came back and the undo history the rebase had
|
|
||||||
// kept went with it. (A failure resyncs below, which puts the row back where it belongs.)
|
|
||||||
const before = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
|
|
||||||
// The STORED order it stood in — the Undo puts it back after its neighbour there (the server's
|
|
||||||
// list, so a view sort on screen does not decide it; `reinsertAt`, so a change in between does
|
|
||||||
// not shift it off by the rows added or moved meanwhile).
|
|
||||||
const storedBefore = before?.items.map((v) => v.id) ?? [];
|
|
||||||
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
|
|
||||||
old ? { ...old, items: old.items.filter((v) => v.id !== video.id) } : old,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await removePlaylistItem(qc, listRef(listId), video.id);
|
|
||||||
refreshAll(listId);
|
|
||||||
} catch (e) {
|
|
||||||
// Said here, as moveItemTo does, or a removal fails in silence (inside the full-screen player
|
|
||||||
// there is not even a row snapping back to show it) — unless `req` already put it in its error
|
|
||||||
// dialog, which `notifyActionError` checks.
|
|
||||||
notifyActionError(e, msg("playlists.removeFailed"));
|
|
||||||
// …and the row goes back NOW, not when the resync lands: a removal that failed because the
|
|
||||||
// server was unreachable has a resync that fails too, and the list kept showing the row as
|
|
||||||
// removed while it was still in the playlist.
|
|
||||||
if (before) qc.setQueryData(qk.playlist(listId), before);
|
|
||||||
refreshAll(listId);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
notify({
|
|
||||||
level: "success",
|
|
||||||
message: msg("playlists.removedToast", { title: raw(video.title ?? "") }),
|
|
||||||
action: {
|
|
||||||
label: msg("common.undo"),
|
|
||||||
// ONE request that puts it back at its index (the server renumbers around it): an append
|
|
||||||
// plus a whole-list reorder would land it at the end whenever the second call failed. The
|
|
||||||
// index is worked out against the list as it is NOW, fetched fresh.
|
|
||||||
onClick: () =>
|
|
||||||
void qc
|
|
||||||
.fetchQuery({
|
|
||||||
queryKey: qk.playlist(listId),
|
|
||||||
queryFn: () => api.playlist(listId),
|
|
||||||
staleTime: 0,
|
|
||||||
})
|
|
||||||
.then((now) => {
|
|
||||||
const back = reinsertAt(
|
|
||||||
storedBefore,
|
|
||||||
[video.id],
|
|
||||||
now.items.map((v) => v.id),
|
|
||||||
);
|
|
||||||
if (back.keys.length)
|
|
||||||
return addPlaylistItem(qc, listRef(listId), video.id, back.positions[0]);
|
|
||||||
})
|
|
||||||
.catch((e: unknown) => notifyActionError(e, msg("playlists.undoRemoveFailed")))
|
|
||||||
.finally(() => {
|
|
||||||
refreshAll(listId);
|
|
||||||
qc.invalidateQueries({ queryKey: qk.playlistMembership(video.id) });
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
// The drag announcements read `shownRef` too: the CURRENT positions, without making their closures
|
// The drag announcements read `shownRef` too: the CURRENT positions, without making their closures
|
||||||
// a render dependency of DndContext; `removeRef` keeps the memoized column callbacks stable.
|
// a render dependency of DndContext; `removeRef` keeps the memoized column callbacks stable.
|
||||||
@@ -756,13 +719,29 @@ function PlaylistDetail() {
|
|||||||
linkedOutOfReach && selectedId === railMovedTo ? linkedOver : selectedId,
|
linkedOutOfReach && selectedId === railMovedTo ? linkedOver : selectedId,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Off the screen at once, and out of the cached list too, for the reason `removeItem` gives. Returns
|
/** Off the screen at once. Returns the cached list as it stood: the place an Undo goes back to, and
|
||||||
// the cached list as it stood: the place an Undo goes back to, and what a failure puts back.
|
* what a failure puts back. */
|
||||||
function takeOut(listId: number, ids: readonly string[]) {
|
function takeOut(listId: number, ids: readonly string[]) {
|
||||||
const gone = new Set(ids);
|
const gone = new Set(ids);
|
||||||
const next = order.value.filter((v) => !gone.has(v.id));
|
// Only the list ON SCREEN has rows to take out optimistically — the open player can be removing
|
||||||
order.rebase((list) => list.filter((v) => !gone.has(v.id)));
|
// from the list it was launched from while another one is selected.
|
||||||
lastSetRef.current = `${listId}:${idsKey(next)}`;
|
if (listId === selectedRef.current) {
|
||||||
|
// Not itself on the reorder undo stack, but it no longer THROWS AWAY that history: rebasing
|
||||||
|
// drops the rows from every snapshot, so the stack stays valid for what's left.
|
||||||
|
const next = order.value.filter((v) => !gone.has(v.id));
|
||||||
|
order.rebase((list) => list.filter((v) => !gone.has(v.id)));
|
||||||
|
lastSetRef.current = `${listId}:${idsKey(next)}`;
|
||||||
|
// The table goes with its last row: a focus in it or in the band would fall to <body>, and the
|
||||||
|
// next Tab start over at the top of the page. The list's "…" takes it.
|
||||||
|
const held = document.activeElement;
|
||||||
|
if (!next.length && (tableRef.current?.contains(held) || bandRef.current?.contains(held)))
|
||||||
|
renameTarget.current?.focus();
|
||||||
|
}
|
||||||
|
// …and out of the CACHED list too, not just the rows. Anything that rewrites that cache entry
|
||||||
|
// before the refetch lands handed the membership guard a list that still held the video — the
|
||||||
|
// player's own progress checkpoint does exactly that when a removal steps it to the next item —
|
||||||
|
// so the guard reset the order: the removed row came back and the undo history the rebase had
|
||||||
|
// kept went with it. (A failure puts it back: `putBack`.)
|
||||||
const before = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
|
const before = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
|
||||||
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
|
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
|
||||||
old ? { ...old, items: old.items.filter((v) => !gone.has(v.id)) } : old,
|
old ? { ...old, items: old.items.filter((v) => !gone.has(v.id)) } : old,
|
||||||
@@ -785,6 +764,36 @@ function PlaylistDetail() {
|
|||||||
}
|
}
|
||||||
const titleOf = (rows: Video[], id: string | undefined) =>
|
const titleOf = (rows: Video[], id: string | undefined) =>
|
||||||
raw(rows.find((v) => v.id === id)?.title ?? "");
|
raw(rows.find((v) => v.id === id)?.title ?? "");
|
||||||
|
// What an action on this one row hands a failure to tick again.
|
||||||
|
const tickOf = (v: Video) => (selection.isSelected(v.id) ? [v.id] : []);
|
||||||
|
// Where rows go back when their Undo comes: against the list as it is NOW, fetched fresh, and after
|
||||||
|
// their neighbours in the STORED order they stood in, so neither a view sort on screen nor a change
|
||||||
|
// in between shifts them (`reinsertAt`).
|
||||||
|
const placesNow = (listId: number, storedBefore: string[], ids: string[]) =>
|
||||||
|
qc
|
||||||
|
.fetchQuery({
|
||||||
|
queryKey: qk.playlist(listId),
|
||||||
|
queryFn: () => api.playlist(listId),
|
||||||
|
staleTime: 0,
|
||||||
|
})
|
||||||
|
.then((now) =>
|
||||||
|
reinsertAt(
|
||||||
|
storedBefore,
|
||||||
|
ids,
|
||||||
|
now.items.map((v) => v.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// An Undo that could not bring everything back says so: the server leaves out a video it cannot put
|
||||||
|
// back (gone on from the target, or from the catalog), and silence would read as all of them.
|
||||||
|
function sayWhatCameBack(asked: number, added: number) {
|
||||||
|
if (added >= asked) return;
|
||||||
|
notify({
|
||||||
|
level: "warning",
|
||||||
|
message: added
|
||||||
|
? msg("playlists.undoSomeBack", { count: added })
|
||||||
|
: msg("playlists.undoNoneBack"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Move rows into another list, in ONE request (see the endpoint). `ticked`: the ones the selection
|
/** Move rows into another list, in ONE request (see the endpoint). `ticked`: the ones the selection
|
||||||
* held, which a failure ticks again. */
|
* held, which a failure ticks again. */
|
||||||
@@ -828,21 +837,11 @@ function PlaylistDetail() {
|
|||||||
: msg("playlists.movedManyToast", { count: left.length, name }),
|
: msg("playlists.movedManyToast", { count: left.length, name }),
|
||||||
action: {
|
action: {
|
||||||
label: msg("common.undo"),
|
label: msg("common.undo"),
|
||||||
// The places worked out against the list as it is NOW, as in `removeItem`. A video back in
|
// A video back in the source by then stays where it is, in both lists. One gone on from the
|
||||||
// the source by then stays where it is, in both lists.
|
// target stays out: the server says how many came back.
|
||||||
onClick: () =>
|
onClick: () =>
|
||||||
void qc
|
void placesNow(listId, storedBefore, left)
|
||||||
.fetchQuery({
|
.then((back) => {
|
||||||
queryKey: qk.playlist(listId),
|
|
||||||
queryFn: () => api.playlist(listId),
|
|
||||||
staleTime: 0,
|
|
||||||
})
|
|
||||||
.then((now) => {
|
|
||||||
const back = reinsertAt(
|
|
||||||
storedBefore,
|
|
||||||
left,
|
|
||||||
now.items.map((v) => v.id),
|
|
||||||
);
|
|
||||||
const place = new Map(back.keys.map((id, i) => [id, back.positions[i]!]));
|
const place = new Map(back.keys.map((id, i) => [id, back.positions[i]!]));
|
||||||
const keep = (list: string[]) => list.filter((id) => place.has(id));
|
const keep = (list: string[]) => list.filter((id) => place.has(id));
|
||||||
const undoMoved = keep(moved);
|
const undoMoved = keep(moved);
|
||||||
@@ -852,7 +851,7 @@ function PlaylistDetail() {
|
|||||||
moved: undoMoved,
|
moved: undoMoved,
|
||||||
already_there: undoHeld,
|
already_there: undoHeld,
|
||||||
positions: [...undoMoved, ...undoHeld].map((id) => place.get(id)!),
|
positions: [...undoMoved, ...undoHeld].map((id) => place.get(id)!),
|
||||||
});
|
}).then((r) => sayWhatCameBack(back.keys.length, r.added));
|
||||||
})
|
})
|
||||||
.catch((e: unknown) =>
|
.catch((e: unknown) =>
|
||||||
notifyActionError(
|
notifyActionError(
|
||||||
@@ -865,81 +864,89 @@ function PlaylistDetail() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove rows in ONE request, with an Undo that puts them back at their places in one too. */
|
/** Remove rows in ONE request, with an Undo that puts them back at their places in one too: an
|
||||||
async function removeRows(rows: Video[], ticked: readonly string[]) {
|
* append plus a whole-list reorder would land them at the end whenever the second call failed.
|
||||||
if (selectedId == null || !rows.length) return;
|
* `ticked`: the ones the selection held, which a failure ticks again.
|
||||||
const listId = selectedId;
|
* @returns whether the write went through. */
|
||||||
|
async function removeRows(
|
||||||
|
rows: Video[],
|
||||||
|
ticked: readonly string[],
|
||||||
|
listIdArg?: number | null,
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Defaulted here, not as `listIdArg = selectedRef.current` in the signature: the React Compiler
|
||||||
|
// cannot reorder a ref read in a default parameter and skipped the component for it.
|
||||||
|
const listId = listIdArg === undefined ? selectedRef.current : listIdArg;
|
||||||
|
if (listId == null || !rows.length) return false;
|
||||||
const ids = rows.map((v) => v.id);
|
const ids = rows.map((v) => v.id);
|
||||||
const list = listRef(listId);
|
const list = listRef(listId);
|
||||||
const before = takeOut(listId, ids);
|
const before = takeOut(listId, ids);
|
||||||
const failTitle = msg(
|
const failTitle = msg(
|
||||||
rows.length > 1 ? "playlists.removeManyFailed" : "playlists.removeFailed",
|
rows.length > 1 ? "playlists.removeManyFailed" : "playlists.removeFailed",
|
||||||
);
|
);
|
||||||
let removed: number | null = null;
|
let removed: string[] | null = null;
|
||||||
try {
|
try {
|
||||||
removed = (await removePlaylistItems(qc, list, ids)).removed;
|
removed = (await removePlaylistItems(qc, list, ids)).removed;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Said here, or a removal fails in silence (inside the full-screen player there is not even a row
|
||||||
|
// snapping back to show it) — unless `req` already put it in its error dialog, which
|
||||||
|
// `notifyActionError` checks.
|
||||||
notifyActionError(e, failTitle);
|
notifyActionError(e, failTitle);
|
||||||
}
|
}
|
||||||
refreshAfterEdit(ids);
|
refreshAfterEdit(ids);
|
||||||
if (removed == null) {
|
if (removed == null) {
|
||||||
|
// …and the rows go back NOW, not when the resync lands: a removal that failed because the server
|
||||||
|
// was unreachable has a resync that fails too.
|
||||||
putBack(listId, before, ticked);
|
putBack(listId, before, ticked);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
if (removed === 0) {
|
// A video someone else took out meanwhile is not this removal's to bring back.
|
||||||
|
const went = removed;
|
||||||
|
if (!went.length) {
|
||||||
notify({ level: "warning", message: msg("playlists.removeNoneLeft") });
|
notify({ level: "warning", message: msg("playlists.removeNoneLeft") });
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
const storedBefore = before?.items.map((v) => v.id) ?? [];
|
const storedBefore = before?.items.map((v) => v.id) ?? [];
|
||||||
notify({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
message:
|
message:
|
||||||
removed === 1 && rows.length === 1
|
went.length === 1 && rows.length === 1
|
||||||
? msg("playlists.removedToast", { title: titleOf(rows, ids[0]) })
|
? msg("playlists.removedToast", { title: titleOf(rows, went[0]) })
|
||||||
: msg("playlists.removedManyToast", { count: removed }),
|
: msg("playlists.removedManyToast", { count: went.length }),
|
||||||
action: {
|
action: {
|
||||||
label: msg("common.undo"),
|
label: msg("common.undo"),
|
||||||
onClick: () =>
|
onClick: () =>
|
||||||
void qc
|
void placesNow(listId, storedBefore, went)
|
||||||
.fetchQuery({
|
.then((back) => {
|
||||||
queryKey: qk.playlist(listId),
|
|
||||||
queryFn: () => api.playlist(listId),
|
|
||||||
staleTime: 0,
|
|
||||||
})
|
|
||||||
.then((now) => {
|
|
||||||
const back = reinsertAt(
|
|
||||||
storedBefore,
|
|
||||||
ids,
|
|
||||||
now.items.map((v) => v.id),
|
|
||||||
);
|
|
||||||
if (back.keys.length)
|
if (back.keys.length)
|
||||||
return restorePlaylistItems(qc, list, back.keys, back.positions);
|
return restorePlaylistItems(qc, list, back.keys, back.positions).then((r) =>
|
||||||
|
sayWhatCameBack(back.keys.length, r.added),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch((e: unknown) =>
|
.catch((e: unknown) =>
|
||||||
notifyActionError(
|
notifyActionError(
|
||||||
e,
|
e,
|
||||||
msg(
|
msg(
|
||||||
ids.length > 1 ? "playlists.undoRemoveManyFailed" : "playlists.undoRemoveFailed",
|
went.length > 1 ? "playlists.undoRemoveManyFailed" : "playlists.undoRemoveFailed",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.finally(() => refreshAfterEdit(ids)),
|
.finally(() => refreshAfterEdit(went)),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The band goes when the selection does. A focus inside it would fall to <body>, and the next Tab
|
// The band goes when the selection does. A focus inside it would fall to <body>, and the next Tab
|
||||||
// start over at the top of the page: the header's box takes it, or, when the action leaves no row
|
// start over at the top of the page: the header's box takes it (`takeOut` moves it on when the
|
||||||
// and so no box, the list's "…".
|
// action leaves no row and so no box).
|
||||||
function leaveBand(emptied = false) {
|
function leaveBand() {
|
||||||
if (!bandRef.current?.contains(document.activeElement)) return;
|
if (bandRef.current?.contains(document.activeElement)) selectAllRef.current?.focus();
|
||||||
(emptied ? renameTarget.current : selectAllRef.current)?.focus();
|
|
||||||
}
|
}
|
||||||
// The selection goes as the action starts: its rows are leaving, and a tick a filter hides would
|
// The selection goes as the action starts: its rows are leaving, and a tick a filter hides would
|
||||||
// come back later on a row the reader has forgotten about. A failure ticks the rows acted on again.
|
// come back later on a row the reader has forgotten about. A failure ticks the rows acted on again.
|
||||||
function selectionRows() {
|
function selectionRows() {
|
||||||
const rows = shown.filter((v) => selection.isSelected(v.id));
|
const rows = shown.filter((v) => selection.isSelected(v.id));
|
||||||
leaveBand(rows.length === items.length);
|
leaveBand();
|
||||||
selection.clear();
|
selection.clear();
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
@@ -1039,6 +1046,12 @@ function PlaylistDetail() {
|
|||||||
// nothing to repeat: before the first move, or when the remembered list IS the one you are looking
|
// nothing to repeat: before the first move, or when the remembered list IS the one you are looking
|
||||||
// at, which is not a move at all. Nothing to explain, so nothing to leave behind greyed out.
|
// at, which is not a move at all. Nothing to explain, so nothing to leave behind greyed out.
|
||||||
const recentMoveTarget = moveTargets.find((p) => p.id === lastMoveTargetId);
|
const recentMoveTarget = moveTargets.find((p) => p.id === lastMoveTargetId);
|
||||||
|
// `playlists` is empty until the rail's list is in, too: no targets only once it says so.
|
||||||
|
const noTargetsNote = listQuery.data
|
||||||
|
? t("playlists.moveNoTargets")
|
||||||
|
: listQuery.isError
|
||||||
|
? t("playlists.moveTargetsFailed")
|
||||||
|
: t("playlists.moveTargetsLoading");
|
||||||
const storedOrderEditable = canEdit && !viewActive;
|
const storedOrderEditable = canEdit && !viewActive;
|
||||||
// While the rows must stay put the others make no way: no row is "over" anything.
|
// While the rows must stay put the others make no way: no row is "over" anything.
|
||||||
const dragCollision: CollisionDetection = (args) =>
|
const dragCollision: CollisionDetection = (args) =>
|
||||||
@@ -1059,6 +1072,10 @@ function PlaylistDetail() {
|
|||||||
carriedRef.current.length > 1
|
carriedRef.current.length > 1
|
||||||
? t("playlists.dragPickedMany", { count: carriedRef.current.length })
|
? t("playlists.dragPickedMany", { count: carriedRef.current.length })
|
||||||
: base.onDragStart(args),
|
: base.onDragStart(args),
|
||||||
|
onDragCancel: (args: Parameters<typeof base.onDragCancel>[0]) =>
|
||||||
|
carriedRef.current.length > 1
|
||||||
|
? t("playlists.dragCancelledMany", { count: carriedRef.current.length })
|
||||||
|
: base.onDragCancel(args),
|
||||||
onDragMove: () => {
|
onDragMove: () => {
|
||||||
const id = railDropTarget();
|
const id = railDropTarget();
|
||||||
return id == null
|
return id == null
|
||||||
@@ -1067,7 +1084,9 @@ function PlaylistDetail() {
|
|||||||
},
|
},
|
||||||
onDragEnd: (args: Parameters<typeof base.onDragEnd>[0]) =>
|
onDragEnd: (args: Parameters<typeof base.onDragEnd>[0]) =>
|
||||||
droppedOnRef.current == null
|
droppedOnRef.current == null
|
||||||
? base.onDragEnd(args)
|
? carriedRef.current.length > 1
|
||||||
|
? t("playlists.dragCancelledMany", { count: carriedRef.current.length })
|
||||||
|
: base.onDragEnd(args)
|
||||||
: t("playlists.dragDroppedOnList", { name: listNameOf.current(droppedOnRef.current) }),
|
: t("playlists.dragDroppedOnList", { name: listNameOf.current(droppedOnRef.current) }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1106,7 +1125,7 @@ function PlaylistDetail() {
|
|||||||
label: t("playlists.moveToRecent", { name: plName(recentMoveTarget) }),
|
label: t("playlists.moveToRecent", { name: plName(recentMoveTarget) }),
|
||||||
icon: <History className="w-4 h-4" />,
|
icon: <History className="w-4 h-4" />,
|
||||||
separatorBefore: true,
|
separatorBefore: true,
|
||||||
onSelect: () => void moveRows([ctxRow], recentMoveTarget),
|
onSelect: () => void moveRows([ctxRow], recentMoveTarget, tickOf(ctxRow)),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
@@ -1121,7 +1140,7 @@ function PlaylistDetail() {
|
|||||||
id: `move-${pl.id}`,
|
id: `move-${pl.id}`,
|
||||||
label: plName(pl),
|
label: plName(pl),
|
||||||
icon: moveTargetIcon(pl),
|
icon: moveTargetIcon(pl),
|
||||||
onSelect: () => void moveRows([ctxRow], pl),
|
onSelect: () => void moveRows([ctxRow], pl, tickOf(ctxRow)),
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
: // Kept visible and disabled rather than dropped: an action that vanishes teaches
|
: // Kept visible and disabled rather than dropped: an action that vanishes teaches
|
||||||
@@ -1132,7 +1151,7 @@ function PlaylistDetail() {
|
|||||||
icon: <FolderInput className="w-4 h-4" />,
|
icon: <FolderInput className="w-4 h-4" />,
|
||||||
separatorBefore: !recentMoveTarget,
|
separatorBefore: !recentMoveTarget,
|
||||||
disabled: true,
|
disabled: true,
|
||||||
note: t("playlists.moveNoTargets"),
|
note: noTargetsNote,
|
||||||
onSelect: () => {},
|
onSelect: () => {},
|
||||||
},
|
},
|
||||||
// Same gate as the drag handle; the reason goes under the first of the four rows.
|
// Same gate as the drag handle; the reason goes under the first of the four rows.
|
||||||
@@ -1167,7 +1186,7 @@ function PlaylistDetail() {
|
|||||||
label: t("playlists.moveTo"),
|
label: t("playlists.moveTo"),
|
||||||
icon: <FolderInput className="w-4 h-4" />,
|
icon: <FolderInput className="w-4 h-4" />,
|
||||||
disabled: true,
|
disabled: true,
|
||||||
note: t("playlists.moveNoTargets"),
|
note: noTargetsNote,
|
||||||
onSelect: () => {},
|
onSelect: () => {},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -1289,6 +1308,9 @@ function PlaylistDetail() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
|
<div role="status" className="sr-only">
|
||||||
|
{selectionStatus}
|
||||||
|
</div>
|
||||||
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
|
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
|
||||||
fixed band, so only the video list scrolls. */}
|
fixed band, so only the video list scrolls. */}
|
||||||
<PageToolbar>
|
<PageToolbar>
|
||||||
@@ -1385,7 +1407,7 @@ function PlaylistDetail() {
|
|||||||
leaveBand();
|
leaveBand();
|
||||||
selection.clear();
|
selection.clear();
|
||||||
}}
|
}}
|
||||||
onClearView={viewActive ? clearView : undefined}
|
onClearView={viewActive ? resetView : undefined}
|
||||||
trailing={
|
trailing={
|
||||||
<UndoToolbar
|
<UndoToolbar
|
||||||
canUndo={order.canUndo}
|
canUndo={order.canUndo}
|
||||||
@@ -1425,7 +1447,7 @@ function PlaylistDetail() {
|
|||||||
{viewActive && (
|
{viewActive && (
|
||||||
<Button
|
<Button
|
||||||
variant="link-muted"
|
variant="link-muted"
|
||||||
onClick={clearView}
|
onClick={resetView}
|
||||||
className="text-xs underline underline-offset-2"
|
className="text-xs underline underline-offset-2"
|
||||||
>
|
>
|
||||||
{t("playlists.clearView")}
|
{t("playlists.clearView")}
|
||||||
@@ -1477,7 +1499,7 @@ function PlaylistDetail() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={viewActive ? pointerSensorOnly : sensors}
|
sensors={sensors}
|
||||||
collisionDetection={dragCollision}
|
collisionDetection={dragCollision}
|
||||||
// Not the list, under a drag that is over the rail: it would run away beside it.
|
// Not the list, under a drag that is over the rail: it would run away beside it.
|
||||||
autoScroll={{ canScroll: () => !isOverRail() }}
|
autoScroll={{ canScroll: () => !isOverRail() }}
|
||||||
@@ -1501,7 +1523,7 @@ function PlaylistDetail() {
|
|||||||
{/* table-fixed, not auto: every column but the title has a declared width, so the
|
{/* table-fixed, not auto: every column but the title has a declared width, so the
|
||||||
title absorbs the rest and its `truncate` works. Auto layout sizes columns from
|
title absorbs the rest and its `truncate` works. Auto layout sizes columns from
|
||||||
min-content, and a nowrap title cell's min-content is the entire title. */}
|
min-content, and a nowrap title cell's min-content is the entire title. */}
|
||||||
<table className="w-full table-fixed border-collapse text-sm">
|
<table ref={tableRef} className="w-full table-fixed border-collapse text-sm">
|
||||||
<ColumnHead
|
<ColumnHead
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
sort={viewSort}
|
sort={viewSort}
|
||||||
@@ -1539,8 +1561,15 @@ function PlaylistDetail() {
|
|||||||
return selection.clear();
|
return selection.clear();
|
||||||
},
|
},
|
||||||
removeSelected: () => {
|
removeSelected: () => {
|
||||||
if (!selection.shownSelected.length) return false;
|
if (!bandUp) return false;
|
||||||
removeSelected();
|
// Nothing happens to a row the reader cannot see, and neither to the
|
||||||
|
// row under the keyboard, which no one selected.
|
||||||
|
if (selection.shownSelected.length) removeSelected();
|
||||||
|
else
|
||||||
|
notify({
|
||||||
|
level: "warning",
|
||||||
|
message: msg("playlists.removeAllHidden"),
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1571,12 +1600,13 @@ function PlaylistDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* On <body>: the page's scroller contains anything fixed, and the rail lies outside it.
|
{/* On <body>: the page's scroller contains anything fixed, and the rail lies outside it.
|
||||||
Above every layer (the tooltip's level), and no way back to fly: it drops or stays. Out
|
Above every layer (the tooltip's level, by its token: dnd-kit's `zIndex` takes a number
|
||||||
of the pointer's way: lying under it, it kept the rail's tab from peeking open. */}
|
only, and its `style` comes after it), and no way back to fly: it drops or stays. Out of
|
||||||
{createPortal(
|
the pointer's way: lying under it, it kept the rail's tab from peeking open. */}
|
||||||
|
<Overlay>
|
||||||
<DragOverlay
|
<DragOverlay
|
||||||
dropAnimation={null}
|
dropAnimation={null}
|
||||||
zIndex={100}
|
style={{ zIndex: "var(--z-index-tooltip)" }}
|
||||||
className="pointer-events-none"
|
className="pointer-events-none"
|
||||||
modifiers={[beneathPointer]}
|
modifiers={[beneathPointer]}
|
||||||
>
|
>
|
||||||
@@ -1589,9 +1619,8 @@ function PlaylistDetail() {
|
|||||||
: shown.find((v) => v.id === carried[0])?.title}
|
: shown.find((v) => v.id === carried[0])?.title}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DragOverlay>,
|
</DragOverlay>
|
||||||
document.body,
|
</Overlay>
|
||||||
)}
|
|
||||||
</DndContext>
|
</DndContext>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ReactNode, Ref } from "react";
|
import type { MouseEventHandler, ReactNode, Ref } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChevronDown, FolderInput, History, ListRestart, Square, X } from "lucide-react";
|
import { ChevronDown, FolderInput, History, ListRestart, Square, X } from "lucide-react";
|
||||||
import { Button } from "../../components/ui/Button";
|
import { Button } from "../../components/ui/Button";
|
||||||
@@ -8,7 +8,8 @@ import { Button } from "../../components/ui/Button";
|
|||||||
* it leaves alone. With only hidden ones the actions stay in place, off, and the way out stays on.
|
* it leaves alone. With only hidden ones the actions stay in place, off, and the way out stays on.
|
||||||
* Below `xl` the buttons are glyphs named in their tooltips: in words the row wraps there, and the
|
* Below `xl` the buttons are glyphs named in their tooltips: in words the row wraps there, and the
|
||||||
* first tick would push the list from under the second tap. On a phone the count is its number and
|
* first tick would push the list from under the second tap. On a phone the count is its number and
|
||||||
* the undo pair stays out, for the same one row. */
|
* the undo pair stays out, for the same one row. The page says the count out loud: this band goes
|
||||||
|
* when the selection does, and a live region that leaves with its news announces nothing. */
|
||||||
export function SelectionBand({
|
export function SelectionBand({
|
||||||
ref,
|
ref,
|
||||||
count,
|
count,
|
||||||
@@ -33,14 +34,14 @@ export function SelectionBand({
|
|||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
/** The view's reset, which the row this one replaces offers. */
|
/** The view's reset, which the row this one replaces offers. */
|
||||||
onClearView?: () => void;
|
onClearView?: MouseEventHandler<HTMLButtonElement>;
|
||||||
trailing: ReactNode;
|
trailing: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const none = count === 0;
|
const none = count === 0;
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="flex items-center gap-1 mt-1 flex-wrap xl:gap-2">
|
<div ref={ref} className="flex items-center gap-1 mt-1 flex-wrap xl:gap-2">
|
||||||
<span role="status" className="text-xs whitespace-nowrap">
|
<span className="text-xs whitespace-nowrap">
|
||||||
<span className="sr-only font-medium sm:not-sr-only">
|
<span className="sr-only font-medium sm:not-sr-only">
|
||||||
{t("playlists.selectedCount", { count })}
|
{t("playlists.selectedCount", { count })}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { escapeHandled } from "../../lib/layerStack";
|
||||||
import {
|
import {
|
||||||
isOverRail,
|
isOverRail,
|
||||||
railDropAreaAttrs,
|
railDropAreaAttrs,
|
||||||
@@ -19,10 +20,10 @@ const runFrame = () => {
|
|||||||
now.forEach((f) => f(0));
|
now.forEach((f) => f(0));
|
||||||
};
|
};
|
||||||
|
|
||||||
function rail() {
|
function rail({ short = false } = {}) {
|
||||||
const scroller = document.createElement("div");
|
const scroller = document.createElement("div");
|
||||||
scroller.style.overflowY = "auto";
|
scroller.style.overflowY = "auto";
|
||||||
Object.defineProperty(scroller, "scrollHeight", { value: 2000 });
|
Object.defineProperty(scroller, "scrollHeight", { value: short ? 400 : 2000 });
|
||||||
Object.defineProperty(scroller, "clientHeight", { value: 400 });
|
Object.defineProperty(scroller, "clientHeight", { value: 400 });
|
||||||
scroller.getBoundingClientRect = () =>
|
scroller.getBoundingClientRect = () =>
|
||||||
({ left: 0, right: 200, top: 100, bottom: 500, width: 200, height: 400 }) as DOMRect;
|
({ left: 0, right: 200, top: 100, bottom: 500, width: 200, height: 400 }) as DOMRect;
|
||||||
@@ -158,6 +159,47 @@ describe("railDrop", () => {
|
|||||||
expect(scroller.scrollTop).toBeLessThan(down);
|
expect(scroller.scrollTop).toBeLessThan(down);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads the row a wheel scrolled under a pointer that fired nothing", () => {
|
||||||
|
const { row } = rail();
|
||||||
|
const r1 = row(1);
|
||||||
|
const r2 = row(2);
|
||||||
|
let lying = r1;
|
||||||
|
under = () => [lying];
|
||||||
|
const { result } = renderHook(() => useRailDropTarget());
|
||||||
|
act(() => {
|
||||||
|
startRailDrag(100, 300);
|
||||||
|
});
|
||||||
|
expect(result.current).toBe(1);
|
||||||
|
lying = r2;
|
||||||
|
act(() => {
|
||||||
|
runFrame();
|
||||||
|
});
|
||||||
|
expect(result.current).toBe(2);
|
||||||
|
expect(stopRailDrag()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the rail it found when there is nothing to scroll, rather than look again each frame", () => {
|
||||||
|
rail({ short: true });
|
||||||
|
startRailDrag(100, 300);
|
||||||
|
const looked = vi.spyOn(window, "getComputedStyle");
|
||||||
|
runFrame();
|
||||||
|
runFrame();
|
||||||
|
expect(looked).not.toHaveBeenCalled();
|
||||||
|
looked.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spends the drag's Esc, and no other", () => {
|
||||||
|
const esc = () => {
|
||||||
|
const e = new KeyboardEvent("keydown", { key: "Escape", bubbles: true });
|
||||||
|
document.dispatchEvent(e);
|
||||||
|
return e;
|
||||||
|
};
|
||||||
|
startRailDrag(100, 300);
|
||||||
|
expect(escapeHandled(esc())).toBe(true);
|
||||||
|
stopRailDrag();
|
||||||
|
expect(escapeHandled(esc())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("finds the rail that opened only during the drag, as an unpinned one peeks", () => {
|
it("finds the rail that opened only during the drag, as an unpinned one peeks", () => {
|
||||||
startRailDrag(100, 495);
|
startRailDrag(100, 495);
|
||||||
runFrame();
|
runFrame();
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
import { markEscapeHandled } from "../../lib/layerStack";
|
||||||
|
import { scrollParent } from "../../lib/listPaging";
|
||||||
|
import { createStore } from "../../lib/store";
|
||||||
|
|
||||||
// Dragging a video onto a list in the rail. The rail and the page are separate React trees (the shell
|
// Dragging a video onto a list in the rail. The rail and the page are separate React trees (the shell
|
||||||
// mounts them side by side, each lazily), so no drag context reaches both: the page's drag reads the
|
// mounts them side by side, each lazily), so no drag context reaches both: the page's drag reads the
|
||||||
@@ -19,31 +21,21 @@ export const railDropAreaAttrs = { [RAIL_AREA]: "" };
|
|||||||
const EDGE = 48;
|
const EDGE = 48;
|
||||||
const MAX_STEP = 12;
|
const MAX_STEP = 12;
|
||||||
|
|
||||||
let over: number | null = null;
|
const over = createStore<number | null>(null);
|
||||||
let inRail = false;
|
let inRail = false;
|
||||||
let point: { x: number; y: number } | null = null;
|
let point: { x: number; y: number } | null = null;
|
||||||
let frame = 0;
|
let frame = 0;
|
||||||
let scroller: HTMLElement | null = null;
|
let scroller: HTMLElement | null = null;
|
||||||
const listeners = new Set<() => void>();
|
|
||||||
|
|
||||||
function setOver(id: number | null) {
|
function setOver(id: number | null) {
|
||||||
if (id === over) return;
|
if (id !== over.get()) over.set(id);
|
||||||
over = id;
|
|
||||||
listeners.forEach((l) => l());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscribe = (l: () => void) => {
|
|
||||||
listeners.add(l);
|
|
||||||
return () => {
|
|
||||||
listeners.delete(l);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The rail row under the drag right now. */
|
/** The rail row under the drag right now. */
|
||||||
export const useRailDropTarget = (): number | null => useSyncExternalStore(subscribe, () => over);
|
export const useRailDropTarget = over.useValue;
|
||||||
|
|
||||||
/** The same, outside React. */
|
/** The same, outside React. */
|
||||||
export const railDropTarget = (): number | null => over;
|
export const railDropTarget = over.get;
|
||||||
|
|
||||||
/** Whether the drag is over the rail at all, where the list's rows must not make way for it. */
|
/** Whether the drag is over the rail at all, where the list's rows must not make way for it. */
|
||||||
export const isOverRail = (): boolean => inRail;
|
export const isOverRail = (): boolean => inRail;
|
||||||
@@ -52,7 +44,7 @@ function readRail() {
|
|||||||
if (!point) return;
|
if (!point) return;
|
||||||
let row: number | null = null;
|
let row: number | null = null;
|
||||||
let area = false;
|
let area = false;
|
||||||
// `elementsFromPoint`, because the drag image lies on top.
|
// `elementsFromPoint`: a toast or a tooltip lying over the rail takes no drop, the row under it does.
|
||||||
for (const el of document.elementsFromPoint(point.x, point.y)) {
|
for (const el of document.elementsFromPoint(point.x, point.y)) {
|
||||||
const hit = el.closest(`[${RAIL_ROW}]`);
|
const hit = el.closest(`[${RAIL_ROW}]`);
|
||||||
if (hit) row = Number(hit.getAttribute(RAIL_ROW));
|
if (hit) row = Number(hit.getAttribute(RAIL_ROW));
|
||||||
@@ -64,22 +56,13 @@ function readRail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function railScroller(): HTMLElement | null {
|
function railScroller(): HTMLElement | null {
|
||||||
for (
|
const area = document.querySelector(`[${RAIL_AREA}]`);
|
||||||
let el = document.querySelector<HTMLElement>(`[${RAIL_AREA}]`)?.parentElement;
|
return area && scrollParent(area);
|
||||||
el;
|
|
||||||
el = el.parentElement
|
|
||||||
) {
|
|
||||||
const { overflowY } = getComputedStyle(el);
|
|
||||||
if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight)
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** With 45 lists the row wanted is usually out of sight, and dnd-kit scrolls only what holds the
|
/** With 45 lists the row wanted is usually out of sight, and dnd-kit scrolls only what holds the
|
||||||
* dragged row: not the rail. */
|
* dragged row: not the rail. */
|
||||||
function scrollStep() {
|
function scrollAtEdge() {
|
||||||
frame = requestAnimationFrame(scrollStep);
|
|
||||||
// Looked for again: an unpinned rail has no rows until the drag's own hover peeks it open.
|
// Looked for again: an unpinned rail has no rows until the drag's own hover peeks it open.
|
||||||
if (!scroller?.isConnected) scroller = railScroller();
|
if (!scroller?.isConnected) scroller = railScroller();
|
||||||
if (!point || !scroller) return;
|
if (!point || !scroller) return;
|
||||||
@@ -93,8 +76,14 @@ function scrollStep() {
|
|||||||
: fromBottom < EDGE
|
: fromBottom < EDGE
|
||||||
? MAX_STEP * (1 - Math.max(fromBottom, 0) / EDGE)
|
? MAX_STEP * (1 - Math.max(fromBottom, 0) / EDGE)
|
||||||
: 0;
|
: 0;
|
||||||
if (step === 0) return;
|
if (step !== 0) scroller.scrollTop += step;
|
||||||
scroller.scrollTop += step;
|
}
|
||||||
|
|
||||||
|
function followStep() {
|
||||||
|
frame = requestAnimationFrame(followStep);
|
||||||
|
scrollAtEdge();
|
||||||
|
// Every frame, not on a move alone: a wheel scrolls another row under a pointer that fires nothing,
|
||||||
|
// and the drop would land on the row that scrolled away.
|
||||||
readRail();
|
readRail();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,20 +92,28 @@ const onPointerMove = (e: PointerEvent) => {
|
|||||||
readRail();
|
readRail();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The drag's Esc is spent on the drag. dnd-kit's pointer sensor cancels on the document and prevents
|
||||||
|
// no default, so without this the list's Esc clears the selection on the same key press.
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") markEscapeHandled(e);
|
||||||
|
};
|
||||||
|
|
||||||
/** Follows the pointer from where the drag began, until `stopRailDrag`. */
|
/** Follows the pointer from where the drag began, until `stopRailDrag`. */
|
||||||
export function startRailDrag(x: number, y: number): void {
|
export function startRailDrag(x: number, y: number): void {
|
||||||
stopRailDrag();
|
stopRailDrag();
|
||||||
point = { x, y };
|
point = { x, y };
|
||||||
scroller = railScroller();
|
scroller = railScroller();
|
||||||
document.addEventListener("pointermove", onPointerMove, true);
|
document.addEventListener("pointermove", onPointerMove, true);
|
||||||
frame = requestAnimationFrame(scrollStep);
|
window.addEventListener("keydown", onKeyDown, true);
|
||||||
|
frame = requestAnimationFrame(followStep);
|
||||||
readRail();
|
readRail();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ends the following, and answers the row the drag was over. Safe without a drag. */
|
/** Ends the following, and answers the row the drag was over. Safe without a drag. */
|
||||||
export function stopRailDrag(): number | null {
|
export function stopRailDrag(): number | null {
|
||||||
const last = over;
|
const last = over.get();
|
||||||
document.removeEventListener("pointermove", onPointerMove, true);
|
document.removeEventListener("pointermove", onPointerMove, true);
|
||||||
|
window.removeEventListener("keydown", onKeyDown, true);
|
||||||
cancelAnimationFrame(frame);
|
cancelAnimationFrame(frame);
|
||||||
point = null;
|
point = null;
|
||||||
scroller = null;
|
scroller = null;
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ export default defineConfig({
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
proxy,
|
proxy,
|
||||||
// What an e2e run writes is no source. On Windows a file it is rewriting answers the watcher with
|
// What an e2e run writes is no source. On Windows a file it is rewriting answers the watcher with
|
||||||
// EBUSY, which takes the whole dev server down; each run also reloaded every open page.
|
// EBUSY, which takes the whole dev server down; each run also reloaded every open page. (Its
|
||||||
watch: { ignored: ["**/playwright-report/**", "**/test-results/**"] },
|
// `test-results` is a default of vite's, and `ignored` adds to the defaults.)
|
||||||
|
watch: { ignored: ["**/playwright-report/**", "**/e2e/.auth/**"] },
|
||||||
},
|
},
|
||||||
build: { outDir: "dist", rollupOptions: { input: ["index.html", "watch.html"] } },
|
build: { outDir: "dist", rollupOptions: { input: ["index.html", "watch.html"] } },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user