From 75e3627ebc0522df6ff9ffa7c281077391b94aa5 Mon Sep 17 00:00:00 2001 From: Michael Schwobe Date: Tue, 14 Nov 2023 12:26:58 -0600 Subject: [PATCH] MAJOR delete/favorite/status/table refactor: - Added `getStatus` and `getStatuses` models - Added `columnSelectable` - Added `columnCreatedAt` - Added `columnTagRelations` - Added `columnBookmarkStatus` - Added `columnBookmarkTitle` - Added `columnBookmarkUrl` - Added `columnBookmarksDelete` - Added `columnBookmarksFavorite` - Added `TableSelectable` - Added `TableBookmarksStatus` - Added `ButtonColumnSort` - Updated/Moved/Renamed `BookmarksTable` to `TableBookmarks` - Updated/Moved/Renamed `Favorite` to `ButtonFavorite` - Updated `ButtonCancel` - Updated `ButtonDelete` - Updated table components styles - Updated/Moved bookmark detail page "actions" to edit page - Updated/Moved tag detail page "actions" to edit page - Updated/Moved Playwright util `encodeUrlRedirectTo` to `encodeUrl` - Updated TODOs --- README.md | 43 +- app/components/bookmarks-table.tsx | 410 ------------------ app/components/button-cancel.tsx | 16 +- app/components/button-column-sort.tsx | 55 +++ app/components/button-delete.tsx | 119 ++--- app/components/button-export.tsx | 54 +++ app/components/button-favorite.tsx | 79 ++++ app/components/favorite.tsx | 87 ---- app/components/latest-bookmarks.tsx | 6 +- app/components/table-bookmarks-status.tsx | 87 ++++ app/components/table-bookmarks.tsx | 104 +++++ app/components/table-columns.tsx | 209 +++++++++ app/components/table-selectable.tsx | 153 +++++++ app/components/ui/table.tsx | 24 +- app/models/status.server.ts | 47 ++ app/routes/bookmarks.$bookmarkId._index.tsx | 59 +-- app/routes/bookmarks.$bookmarkId.edit.tsx | 22 +- app/routes/bookmarks._index.tsx | 27 +- app/routes/bookmarks.offset.tsx | 17 +- app/routes/bookmarks.status.tsx | 113 +++++ app/routes/tags.$tagId._index.tsx | 37 +- app/routes/tags.$tagId.edit.tsx | 7 +- app/utils/bookmark-exports.server.ts | 6 +- .../e2e/bookmarks.$bookmarkId._index.test.ts | 25 +- tests/e2e/bookmarks.$bookmarkId.edit.test.ts | 7 +- tests/e2e/bookmarks._index.test.ts | 18 +- tests/e2e/bookmarks.new.test.ts | 4 +- tests/e2e/tags.$tagId._index.test.ts | 21 +- tests/e2e/tags.$tagId.edit.test.ts | 7 +- tests/e2e/tags.$tagId.merge.test.ts | 7 +- tests/e2e/tags.$tagId.split.test.ts | 7 +- tests/e2e/tags._index.test.ts | 8 +- tests/e2e/tags.new.test.ts | 4 +- tests/utils/playwright-test-utils.ts | 13 +- 34 files changed, 1137 insertions(+), 765 deletions(-) delete mode 100644 app/components/bookmarks-table.tsx create mode 100644 app/components/button-column-sort.tsx create mode 100644 app/components/button-export.tsx create mode 100644 app/components/button-favorite.tsx delete mode 100644 app/components/favorite.tsx create mode 100644 app/components/table-bookmarks-status.tsx create mode 100644 app/components/table-bookmarks.tsx create mode 100644 app/components/table-columns.tsx create mode 100644 app/components/table-selectable.tsx create mode 100644 app/models/status.server.ts create mode 100644 app/routes/bookmarks.status.tsx diff --git a/README.md b/README.md index cd834bf..ddf981c 100644 --- a/README.md +++ b/README.md @@ -11,26 +11,23 @@ TagsForDays extends traditional bookmarking with advanced organization and searc > Warning >
> -> This project is still in development and all items are subject to change and in no specific order. - -- TODO: Tag: trending widget (relation count x relation dates) -- TODO: Tag: popular widget (relation count) -- TODO: Tag: tag colors or other fields - -- TODO: Bookmark: assess orderBy (title, date, etc.) -- TODO: Bookmark: watchtower route (dead links, redirects, etc.) -- TODO: Bookmark: suggest/postfetch-opt-in title/description -- TODO: Bookmark: suggest tags - -- TODO: Collection: collection model (grouped bookmarks, tagged) - -- TODO: User: multitenancy -- TODO: User: profiles - -- TODO: General: finalize pagination (cursor vs offset) -- TODO: General: performance optimizations -- TODO: General: more data (seeded, production, etc.) -- TODO: General: db writes/resets when testing -- TODO: General: tailwindcss config for custom colors, spacing, etc. -- TODO: General: all/model-filtered data resouce route(s) -- TODO: General: more Unit tests / enable skipped tests when db writes/resets are implemented +> This project is still in development and all TODOs are subject to change. + +### MVP + +- TODO: Finalize pagination (cursor vs offset, remove `bookmarks.offset.tsx` route) +- TODO: Finalize tables (construction and types) +- TODO: Add database writes/resets (seeding, testing) +- TODO: Add database "Collection" model (grouped bookmarks, relations, other) +- Complete all TODOs found in codebase + +### Other + +- TODO: Update button/link variant (warning, success) +- TODO: Suggest bookmark title/description/tags (action) +- TODO: Create "Unused Tags" widget (relation count) +- TODO: Create "Popular Tags" widget (relation count x createdAt) +- TODO: Add more E2E and Unit tests +- TODO: Optimize performance +- TODO: Custom tailwindcss config (custom colors, spacing, etc.) +- TODO: Add user multitenancy / profiles diff --git a/app/components/bookmarks-table.tsx b/app/components/bookmarks-table.tsx deleted file mode 100644 index d087f09..0000000 --- a/app/components/bookmarks-table.tsx +++ /dev/null @@ -1,410 +0,0 @@ -import { Form } from "@remix-run/react"; -import type { ColumnDef } from "@tanstack/react-table"; -import { - createColumnHelper, - flexRender, - getCoreRowModel, - getSortedRowModel, - useReactTable, -} from "@tanstack/react-table"; -import { Favorite } from "~/components/favorite"; -import { Badge } from "~/components/ui/badge"; -import { Button } from "~/components/ui/button"; -import { Checkbox } from "~/components/ui/checkbox"; -import { Favicon } from "~/components/ui/favicon"; -import { Icon } from "~/components/ui/icon"; -import { LinkButton } from "~/components/ui/link-button"; -import { - Table, - TableWrapper, - Tbody, - Td, - Tfoot, - Th, - Thead, - Tr, -} from "~/components/ui/table"; -import type { getBookmarks } from "~/models/bookmark.server"; -import type { ItemWithFaviconSrcProp } from "~/models/favicon.server"; -import { BOOKMARK_EXPORT_LABEL_MAP } from "~/utils/bookmark"; -import { cn } from "~/utils/misc"; - -type GetBookmarksData = Awaited>; - -// 🤷‍♂️ Patched with string type for `createdAt` property. -type GetBookmarksDataItem = Omit & { - createdAt: Date | string; -}; - -type BookmarksTableData = GetBookmarksDataItem & ItemWithFaviconSrcProp; - -const columnHelper = createColumnHelper(); - -// 🤷‍♂️ Flagged as a TS error and @ts-expect-error doesn't work, leaving as is. -// TODO: remove comment once this is fixed. -// See node module bug https://github.com/TanStack/table/issues/5135 -export const bookmarksTableColumns = [ - columnHelper.display({ - id: "select", - header: ({ table }) => ( - row.original.id) - .join(" ")} - checked={table.getIsAllRowsSelected()} - indeterminate={table.getIsSomeRowsSelected()} - onCheckedChange={(checked) => - table.getToggleAllRowsSelectedHandler()({ target: { checked } }) - } - /> - ), - footer: ({ column }) => column.id, - cell: ({ row }) => ( - - row.getToggleSelectedHandler()({ target: { checked } }) - } - /> - ), - }), - - columnHelper.accessor("title", { - header: () => "Title", - footer: ({ column }) => column.id, - cell: ({ row, getValue }) => ( - - ), - }), - - columnHelper.accessor("url", { - header: () => "URL", - footer: ({ column }) => column.id, - cell: ({ getValue }) => , - }), - - columnHelper.accessor("createdAt", { - sortingFn: "datetime", - header: () => ( - - - Date Created - - ), - footer: ({ column }) => column.id, - cell: ({ getValue }) => ( - - {new Date(getValue()).toLocaleDateString()} - - ), - }), - - columnHelper.accessor("_count.tags", { - id: "tagRelations", - sortingFn: "alphanumeric", - header: () => ( - - - Tag Relations - - ), - footer: ({ column }) => column.id, - cell: ({ getValue }) => {getValue()}, - }), - - columnHelper.accessor("favorite", { - header: () => ( - - - Favorite - - ), - footer: ({ column }) => column.id, - cell: ({ row, getValue }) => ( - - ), - }), -]; - -export interface BookmarksTableProps - extends Omit, "children"> { - /** Sets the `class` attribute. */ - className?: string | undefined; - /** Sets table column definitions, display templates, etc. **Required** */ - columns: ColumnDef[]; - /** Sets table data. **Required** */ - data: TData[]; -} - -export function BookmarksTable({ - className, - columns, - data, - ...props -}: BookmarksTableProps) { - const table = useReactTable({ - columns, - data, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - enableRowSelection: true, - }); - - const selectedIds = table - .getSelectedRowModel() - // TODO: remove ts-expect-error once this is fixed - // @ts-expect-error - 🤷‍♂️ 'id' does exist - .rows.map((row) => row.original.id); - - return ( - - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - ); - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - )) - ) : ( - - - - )} - - - - - - - -
- {header.isPlaceholder ? null : header.column.getCanSort() ? ( - - {flexRender( - header.column.columnDef.header, - header.getContext(), - )} - - ) : ( - flexRender( - header.column.columnDef.header, - header.getContext(), - ) - )} -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
- No results. -
- - table.getToggleAllRowsSelectedHandler()({ - target: { checked }, - }) - } - /> - - - - -
-
- ); -} - -function ThButton({ - children, - isSortedAsc, - isSortedDesc, - onClick, -}: { - children: React.ReactNode; - isSortedAsc: boolean; - isSortedDesc: boolean; - onClick: ((event: unknown) => void) | undefined; -}) { - return ( - - ); -} - -function ButtonTitle({ - bookmarkId, - faviconSrc, - title, -}: { - bookmarkId: string; - faviconSrc: string | null | undefined; - title: string | null | undefined; -}) { - return ( - - {" "} - - {title ? {title} : --} - - - ); -} - -function ButtonUrl({ url }: { url: string }) { - return ( - - - {url} - - ); -} - -function ButtonFavorite({ - bookmarkId, - defaultValue, -}: { - bookmarkId: string; - defaultValue: boolean | null | undefined; -}) { - return ( - - ); -} - -function ButtonExportGroup() { - return ( -
- {Object.entries(BOOKMARK_EXPORT_LABEL_MAP).map(([ext, label]) => ( - - {label} - - ))} -
- ); -} - -function ButtonExport({ - children, - formAction, -}: { - children: React.ReactNode; - formAction: string; -}) { - return ( - - ); -} - -function SelectedBookmarksForm({ - children, - selectedIds, - totalLength, -}: { - children: React.ReactNode; - selectedIds: string[]; - totalLength: number; -}) { - return ( -
- -
-
- {selectedIds.length} of {totalLength} rows selected. -
- {children} -
-
- ); -} diff --git a/app/components/button-cancel.tsx b/app/components/button-cancel.tsx index d7c8f29..04a1d64 100644 --- a/app/components/button-cancel.tsx +++ b/app/components/button-cancel.tsx @@ -11,24 +11,34 @@ export interface ButtonCancelProps > { /** Sets the `class` attribute. */ className?: string | undefined; + /** Sets the content. */ + label?: string | undefined; + /** Sets `navigate` params. **Required** */ + to?: string | undefined; } export const ButtonCancel = forwardRef< React.ElementRef<"button">, ButtonCancelProps ->((props, forwardedRef) => { +>(({ label, to, ...props }, forwardedRef) => { const navigate = useNavigate(); return ( ); }); diff --git a/app/components/button-column-sort.tsx b/app/components/button-column-sort.tsx new file mode 100644 index 0000000..5c09e15 --- /dev/null +++ b/app/components/button-column-sort.tsx @@ -0,0 +1,55 @@ +import { forwardRef } from "react"; +import { Button } from "~/components/ui/button"; +import { Icon } from "~/components/ui/icon"; +import { cn } from "~/utils/misc"; + +export interface ButtonColumnSortProps + extends Omit< + React.ComponentPropsWithoutRef, + "onClick" | "size" | "type" | "variant" + > { + /** Sets the icon type **Required** */ + isSortedAsc: boolean; + /** Sets the icon type **Required** */ + isSortedDesc: boolean; + /** Binds the `click` event handler. **Required** */ + onClick: ((event: unknown) => void) | undefined; +} + +export const ButtonColumnSort = forwardRef< + React.ElementRef, + ButtonColumnSortProps +>( + ( + { children, className, isSortedAsc, isSortedDesc, onClick, ...props }, + forwardedRef, + ) => { + return ( + + ); + }, +); + +ButtonColumnSort.displayName = "ButtonColumnSort"; diff --git a/app/components/button-delete.tsx b/app/components/button-delete.tsx index 5b5fde1..ffe740a 100644 --- a/app/components/button-delete.tsx +++ b/app/components/button-delete.tsx @@ -1,94 +1,69 @@ import { conform } from "@conform-to/react"; -import { Form, useLocation, useNavigation } from "@remix-run/react"; +import { useFetcher } from "@remix-run/react"; import { forwardRef } from "react"; -import type { ButtonVariants } from "~/components/ui/button"; import { Button } from "~/components/ui/button"; import { Icon } from "~/components/ui/icon"; -import { LinkButton } from "~/components/ui/link-button"; -import { cn, useDoubleCheck } from "~/utils/misc"; -import { USER_LOGIN_ROUTE, useOptionalUser } from "~/utils/user"; +import { useDoubleCheck } from "~/utils/misc"; export interface ButtonDeleteProps - extends Omit, "children">, - ButtonVariants { - /** Sets the `class` attribute. */ - className?: string | undefined; - /** Sets the content. **Required** */ - singular: string; + extends Omit< + React.ComponentPropsWithoutRef, + "children" | "disabled" | "type" | "value" + > { + /** Sets the form `action` attribute. **Required** */ + formAction: string; + /** Sets the input[hidden] `value` attribute. */ + idsSelected?: string[]; + /** Sets the content. */ + label?: string | undefined; } export const ButtonDelete = forwardRef< - React.ElementRef<"button">, + React.ElementRef, ButtonDeleteProps >( ( - { - className, - onClick, - singular, - size = "md", - variant = "outlined-danger", - ...props - }, + { className, formAction, idsSelected, label, size, variant, ...props }, forwardedRef, ) => { - const location = useLocation(); - const navigation = useNavigation(); - const optionalUser = useOptionalUser(); const doubleCheck = useDoubleCheck(); + const fetcher = useFetcher(); - if (optionalUser) { - const isIdle = navigation.state === "idle"; - const isPending = navigation.state !== "idle"; - const isClick0 = doubleCheck.isPending === false; - const isClick1 = doubleCheck.isPending && isIdle; - const isClick2 = doubleCheck.isPending && isPending; + // Data states + const isIdle = fetcher.state === "idle"; + const isPending = fetcher.state !== "idle"; + // const isClick0 = doubleCheck.isPending === false; + const isClick1 = doubleCheck.isPending && isIdle; + const isClick2 = doubleCheck.isPending && isPending; + const isMultiple = Array.isArray(idsSelected) && idsSelected.length >= 1; - return ( -
- - -
- ); - } + // Input props + const nextValue = isMultiple ? idsSelected : ""; + + // Icon props + const icon = isClick2 ? "loader" : isClick1 ? "alert-triangle" : "trash-2"; + + // Text props + const isIconOnly = size?.includes("icon") ?? false; + const verb = isClick2 ? "Deleting" : isClick1 ? "Confirm delete" : "Delete"; + const text = label ? `${verb} ${label}` : verb; return ( - - - Delete {singular} - + + + + + ); }, ); diff --git a/app/components/button-export.tsx b/app/components/button-export.tsx new file mode 100644 index 0000000..03251bd --- /dev/null +++ b/app/components/button-export.tsx @@ -0,0 +1,54 @@ +import { Form } from "@remix-run/react"; +import { forwardRef } from "react"; +import { Button } from "~/components/ui/button"; +import { Icon } from "~/components/ui/icon"; +import type { BookmarkExportFileExtension } from "~/utils/bookmark"; +import { BOOKMARK_EXPORT_LABEL_MAP } from "~/utils/bookmark"; +import { cn } from "~/utils/misc"; + +export interface TableButtonExportProps + extends Omit< + React.ComponentPropsWithoutRef, + "children" | "size" | "type" | "variant" | "value" + > { + /** Sets the form `action` attribute. **Required** */ + actionRoute: string; + /** Sets the content and form `action` attribute. **Required** */ + fileExtension: BookmarkExportFileExtension; + /** Sets the input[hidden] `value` attribute. **Required** */ + idsSelected: string[]; +} + +export const TableButtonExport = forwardRef< + React.ElementRef, + TableButtonExportProps +>( + ( + { actionRoute, className, fileExtension, idsSelected, ...props }, + forwardedRef, + ) => { + return ( +
+ + +
+ ); + }, +); + +TableButtonExport.displayName = "TableButtonExport"; diff --git a/app/components/button-favorite.tsx b/app/components/button-favorite.tsx new file mode 100644 index 0000000..7e9cc34 --- /dev/null +++ b/app/components/button-favorite.tsx @@ -0,0 +1,79 @@ +import { conform } from "@conform-to/react"; +import { useFetcher } from "@remix-run/react"; +import { forwardRef } from "react"; +import { Button } from "~/components/ui/button"; +import { Icon } from "~/components/ui/icon"; +import { cn } from "~/utils/misc"; + +export interface ButtonFavoriteProps + extends Omit< + React.ComponentPropsWithoutRef, + "children" | "type" | "value" + > { + /** Sets the form `action` attribute. **Required** */ + formAction: string; + /** Sets the input[hidden] `value` attribute. */ + idsSelected?: string[]; + /** Sets the icon type **Required** */ + isFavorite: boolean; + /** Sets the content. */ + label?: string | undefined; +} + +export const ButtonFavorite = forwardRef< + React.ElementRef, + ButtonFavoriteProps +>( + ( + { className, formAction, idsSelected, isFavorite, label, size, ...props }, + forwardedRef, + ) => { + const fetcher = useFetcher(); + + // Data states + const isPending = fetcher.state !== "idle"; + const isFavorited = fetcher.formData + ? fetcher.formData.get("favorite") === "true" + : isFavorite === true; + const isMultiple = Array.isArray(idsSelected) && idsSelected.length >= 1; + + // Input props + const nextName = isMultiple ? "ids-selected" : "favorite"; + const nextValue = isMultiple ? idsSelected : String(!isFavorited); + + // Text props + const isIconOnly = size?.includes("icon") ?? false; + const verb = isMultiple + ? "(Un)favorite" + : isFavorited + ? "Unfavorite" + : "Favorite"; + const text = label ? `${verb} ${label}` : verb; + + return ( + + + + + + ); + }, +); + +ButtonFavorite.displayName = "ButtonFavorite"; diff --git a/app/components/favorite.tsx b/app/components/favorite.tsx deleted file mode 100644 index bf14910..0000000 --- a/app/components/favorite.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { conform } from "@conform-to/react"; -import { useFetcher, useLocation, useNavigation } from "@remix-run/react"; -import type { ButtonVariants } from "~/components/ui/button"; -import { Button } from "~/components/ui/button"; -import { Icon } from "~/components/ui/icon"; -import { LinkButton } from "~/components/ui/link-button"; -import { cn } from "~/utils/misc"; -import { USER_LOGIN_ROUTE, useOptionalUser } from "~/utils/user"; - -export interface FavoriteProps extends ButtonVariants { - /** Sets the `class` attribute. */ - className?: string | undefined; - /** Sets the content and input[name=favorite] `value` attribute. **Required** */ - defaultValue: boolean | null | undefined; - /** Sets the `action` attribute. **Required** */ - formAction: string; -} - -export function Favorite({ - className, - defaultValue, - formAction, - size = "md-icon", - variant = "outlined", -}: FavoriteProps) { - const fetcher = useFetcher(); - const location = useLocation(); - const navigation = useNavigation(); - const optionalUser = useOptionalUser(); - - const isPending = navigation.state !== "idle"; - const isFavorite = fetcher.formData - ? fetcher.formData.get("favorite") === "true" - : defaultValue === true; - const nextValue = String(!isFavorite); - const FavoriteContent = isFavorite ? IconUnFavorite : IconFavorite; - - if (optionalUser) { - return ( - - - - - - ); - } - - return ( - - - - ); -} - -function IconFavorite() { - return ( - <> - - Favorite - - ); -} - -function IconUnFavorite() { - return ( - <> - - Unfavorite - - ); -} diff --git a/app/components/latest-bookmarks.tsx b/app/components/latest-bookmarks.tsx index 94bdcf2..f370039 100644 --- a/app/components/latest-bookmarks.tsx +++ b/app/components/latest-bookmarks.tsx @@ -44,7 +44,7 @@ export const LatestBookmarks = forwardRef<
  • {" "} @@ -60,13 +60,13 @@ export const LatestBookmarks = forwardRef< to={bookmark.url} target="_blank" rel="noopener noreferrer" - className="grow justify-between overflow-hidden font-normal" + className="w-0 grow justify-start overflow-hidden font-normal" variant="ghost" > + {bookmark.url} -
  • ))} diff --git a/app/components/table-bookmarks-status.tsx b/app/components/table-bookmarks-status.tsx new file mode 100644 index 0000000..8afa58b --- /dev/null +++ b/app/components/table-bookmarks-status.tsx @@ -0,0 +1,87 @@ +import type { ColumnDef } from "@tanstack/react-table"; +import { + columnBookmarkStatus, + columnBookmarkTitle, + columnBookmarkUrl, + columnBookmarksDelete, + columnSelectable, +} from "~/components/table-columns"; +import { TableSelectable } from "~/components/table-selectable"; +import { Td } from "~/components/ui/table"; +import type { getBookmarks } from "~/models/bookmark.server"; +import type { GetStatusesData } from "~/models/status.server"; +import { cn } from "~/utils/misc"; + +// TODO: Move classNames back into table. +const classNameMap = { + title: { + button: "justify-start", + }, + url: { + th: "w-full", + button: "justify-start", + }, +}; + +// TODO: Refactor table types. +type GetBookmarksData = Awaited>; +type GetBookmarksDataItem = GetBookmarksData[0]; +type GetBookmarksDataItemPatched = Omit & { + createdAt: Date | string; // 🤷‍♂️ Patched with string type. +}; +type ColumnData = GetStatusesData[0]; + +// TODO: Remove ts-expect-error(s) once this is fixed. +// 🤷‍♂️ Flagged as a TS error and ts-expect-error doesn't work, leaving as is. +// See node module bug https://github.com/TanStack/table/issues/5135 +export const columnsTableBookmarksStatus: ColumnDef[] = [ + // @ts-expect-error - see comment above + columnSelectable, + // @ts-expect-error - see comment above + columnBookmarkStatus, + // @ts-expect-error - see comment above + columnBookmarkTitle, + // @ts-expect-error - see comment above + columnBookmarkUrl, + // @ts-expect-error - see comment above + columnBookmarksDelete, +]; + +export interface TableBookmarksStatusProps + extends Omit, "children"> { + /** Sets the `class` attribute. */ + className?: string | undefined; + /** Sets table column definitions, display templates, etc. **Required** */ + columns: ColumnDef[]; + /** Sets table data. **Required** */ + data: TData[]; +} + +export function TableBookmarksStatus({ + className, + columns, + data, + ...props +}: TableBookmarksStatusProps) { + return ( + + {({ idsSelected }) => ( + <> + +
    +
    + {idsSelected.length} Selected +
    +
    + + + )} +
    + ); +} diff --git a/app/components/table-bookmarks.tsx b/app/components/table-bookmarks.tsx new file mode 100644 index 0000000..f618bfd --- /dev/null +++ b/app/components/table-bookmarks.tsx @@ -0,0 +1,104 @@ +import type { ColumnDef } from "@tanstack/react-table"; +import { TableButtonExport } from "~/components/button-export"; +import { + columnBookmarkTitle, + columnBookmarkUrl, + columnBookmarksFavorite, + columnCreatedAt, + columnSelectable, + columnTagRelations, +} from "~/components/table-columns"; +import { TableSelectable } from "~/components/table-selectable"; +import { Td } from "~/components/ui/table"; +import type { getBookmarks } from "~/models/bookmark.server"; +import type { ItemWithFaviconSrcProp } from "~/models/favicon.server"; +import { BOOKMARK_EXPORT_FILE_EXTENSIONS } from "~/utils/bookmark"; +import { cn } from "~/utils/misc"; + +// TODO: Move classNames back into table. +const classNameMap = { + title: { + button: "justify-start", + }, + url: { + th: "w-full", + button: "justify-start", + }, +}; + +// TODO: Refactor table types. +type GetBookmarksData = Awaited>; +type GetBookmarksDataItem = GetBookmarksData[0]; +type GetBookmarksDataItemPatched = Omit & { + createdAt: Date | string; // 🤷‍♂️ Patched with string type. +}; +type ColumnData = GetBookmarksDataItemPatched & ItemWithFaviconSrcProp; + +// TODO: Remove ts-expect-error(s) once this is fixed. +// 🤷‍♂️ Flagged as a TS error and ts-expect-error doesn't work, leaving as is. +// See node module bug https://github.com/TanStack/table/issues/5135 +export const columnsTableBookmarks: ColumnDef[] = [ + // @ts-expect-error - see comment above + columnSelectable, + // @ts-expect-error - see comment above + columnBookmarkTitle, + // @ts-expect-error - see comment above + columnBookmarkUrl, + // @ts-expect-error - see comment above + columnCreatedAt, + // @ts-expect-error - see comment above + columnTagRelations, + // @ts-expect-error - see comment above + columnBookmarksFavorite, +]; + +export interface TableBookmarksProps + extends Omit, "children"> { + /** Sets the `class` attribute. */ + className?: string | undefined; + /** Sets table column definitions, display templates, etc. **Required** */ + columns: ColumnDef[]; + /** Sets table data. **Required** */ + data: TData[]; +} + +export function TableBookmarks({ + className, + columns, + data, + ...props +}: TableBookmarksProps) { + return ( + + {({ idsSelected, idsNotSelected }) => ( + <> + +
    +
    + {idsSelected.length} Selected +
    +
    + {BOOKMARK_EXPORT_FILE_EXTENSIONS.map((ext) => ( + 0 ? idsSelected : idsNotSelected + } + /> + ))} +
    +
    + + + )} +
    + ); +} diff --git a/app/components/table-columns.tsx b/app/components/table-columns.tsx new file mode 100644 index 0000000..688988e --- /dev/null +++ b/app/components/table-columns.tsx @@ -0,0 +1,209 @@ +import { createColumnHelper } from "@tanstack/react-table"; +import { ButtonDelete } from "~/components/button-delete"; +import { ButtonFavorite } from "~/components/button-favorite"; +import { Badge } from "~/components/ui/badge"; +import { Checkbox } from "~/components/ui/checkbox"; +import { Favicon } from "~/components/ui/favicon"; +import { Icon } from "~/components/ui/icon"; +import { LinkButton } from "~/components/ui/link-button"; +import { cn } from "~/utils/misc"; + +export const columnSelectable = createColumnHelper<{ + id: string; +}>().display({ + id: "select", + header: ({ table }) => ( + { + const handler = table.getToggleAllRowsSelectedHandler(); + return handler({ target: { checked } }); + }} + aria-label="Select all rows" + aria-controls={table + .getSelectedRowModel() + .rows.map((row) => row.original.id) + .join(" ")} + /> + ), + cell: ({ row }) => ( + { + const handler = row.getToggleSelectedHandler(); + return handler({ target: { checked } }); + }} + aria-label="Select row" + aria-controls={row.original.id} + /> + ), + footer: ({ column }) => column.id, +}); + +export const columnCreatedAt = createColumnHelper<{ + createdAt: string; +}>().accessor("createdAt", { + sortingFn: "datetime", + header: () => ( + + + Date Created + + ), + cell: ({ getValue }) => ( + + {new Date(getValue()).toLocaleDateString()} + + ), + footer: ({ column }) => column.id, +}); + +export const columnTagRelations = createColumnHelper<{ + _count: { + tags: number; + }; +}>().accessor("_count.tags", { + id: "tagRelations", + sortingFn: "alphanumeric", + header: () => ( + + + Tag Relations + + ), + cell: ({ getValue }) => (getValue() > 0 ? {getValue()} : null), + footer: ({ column }) => column.id, +}); + +export const columnBookmarkStatus = createColumnHelper<{ + id: string; + _meta: { ok: boolean; status: number; statusText: string }; +}>().accessor((row) => row._meta.status, { + id: "status", + sortingFn: "alphanumeric", + header: () => ( + <> + + Status + + ), + cell: ({ row, getValue }) => ( + = 300 && getValue() <= 499 + ? "warning" + : "danger" + } + > + {getValue()} + {row.original._meta.statusText} + + ), + footer: ({ column }) => column.id, +}); + +export const columnBookmarkTitle = createColumnHelper<{ + id: string; + title: string; + faviconSrc: string | null; +}>().accessor("title", { + header: () => "Title", + cell: ({ row, getValue }) => ( + + {" "} + + {getValue() ? ( + {getValue()} + ) : ( + -- + )} + + + ), + footer: ({ column }) => column.id, +}); + +export const columnBookmarkUrl = createColumnHelper<{ + id: string; + url: string; +}>().accessor("url", { + header: () => "URL", + cell: ({ getValue }) => ( + + + {getValue()} + + ), + footer: ({ column }) => column.id, +}); + +export const columnBookmarksDelete = createColumnHelper<{ + id: string; +}>().accessor("id", { + id: "delete", + enableSorting: false, + header: () => ( + <> + + Delete + + ), + cell: ({ getValue }) => ( + + ), + footer: ({ column }) => column.id, +}); + +export const columnBookmarksFavorite = createColumnHelper<{ + id: string; + favorite: boolean; +}>().accessor("favorite", { + header: () => ( + + + Favorite + + ), + cell: ({ row, getValue }) => ( + + ), + footer: ({ column }) => column.id, +}); diff --git a/app/components/table-selectable.tsx b/app/components/table-selectable.tsx new file mode 100644 index 0000000..78601e6 --- /dev/null +++ b/app/components/table-selectable.tsx @@ -0,0 +1,153 @@ +import type { ColumnDef } from "@tanstack/react-table"; +import { + flexRender, + getCoreRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { ButtonColumnSort } from "~/components/button-column-sort"; +import { Checkbox } from "~/components/ui/checkbox"; +import { + Table, + TableWrapper, + Tbody, + Td, + Tfoot, + Th, + Thead, + Tr, +} from "~/components/ui/table"; +import { cn } from "~/utils/misc"; + +export interface RenderProps { + idsNotSelected: string[]; + idsSelected: string[]; +} + +export interface TableSelectableProps + extends Omit, "children"> { + /** Sets the tfoot tr content. */ + children?: React.ReactNode | ((props: RenderProps) => React.ReactNode); + /** Sets the `class` attribute. */ + className?: string | undefined; + /** Sets the thead th `class` attribute. */ + classNameMap?: Record>; + /** Sets table column definitions, display templates, etc. **Required** */ + columns: ColumnDef[]; + /** Sets table data. **Required** */ + data: TData[]; +} + +export function TableSelectable({ + children, + className, + classNameMap, + columns, + data, + ...props +}: TableSelectableProps) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + enableRowSelection: true, + }); + + const rows = table.getRowModel().rows; + const rowsNotSelected = rows.filter((row) => !row.getIsSelected()); + const rowsSelected = table.getSelectedRowModel().rows; + + // TODO: Remove ts-expect-error(s) once this is fixed. + // @ts-expect-error - 🤷‍♂️ 'id' DOES exist + const idsNotSelected = rowsNotSelected.map((row) => row.original.id); + // @ts-expect-error - 🤷‍♂️ 'id' DOES exist + const idsSelected = rowsSelected.map((row) => row.original.id); + + const renderProps: RenderProps = { idsNotSelected, idsSelected }; + + return ( + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + ); + })} + + ))} + + + {rows?.length ? ( + rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + )) + ) : ( + + + + )} + + {children ? ( + + + + {typeof children === "function" + ? children(renderProps) + : children} + + + ) : null} +
    + {header.isPlaceholder ? null : header.column.getCanSort() ? ( + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ) : ( + flexRender( + header.column.columnDef.header, + header.getContext(), + ) + )} +
    + {flexRender(cell.column.columnDef.cell, cell.getContext())} +
    No results.
    + { + const handler = table.getToggleAllRowsSelectedHandler(); + return handler({ target: { checked } }); + }} + aria-label="Select all rows" + aria-controls={idsSelected.join(" ")} + /> +
    +
    + ); +} diff --git a/app/components/ui/table.tsx b/app/components/ui/table.tsx index d260edd..a337746 100644 --- a/app/components/ui/table.tsx +++ b/app/components/ui/table.tsx @@ -4,7 +4,7 @@ import { cn } from "~/utils/misc"; export const TableWrapper = forwardRef< React.ElementRef<"div">, React.ComponentPropsWithoutRef<"div"> ->(({ children, className, ...props }, forwardedRef) => ( +>(({ className, ...props }, forwardedRef) => (
    - {children} -
    + /> )); TableWrapper.displayName = "TableWrapper"; @@ -55,7 +53,8 @@ export const Thead = forwardRef< = ReadonlyArray< + TData & { _meta: GetStatusData } +>; + +export async function getStatus( + input: string, + timeout: number, +): Promise { + try { + const { ok, status, statusText } = await fetch(input, { + method: "HEAD", + signal: AbortSignal.timeout(timeout), + }); + return { ok, status, statusText }; + } catch (error) { + if (error instanceof Error) { + if ("type" in error && error.type === "aborted") { + return { ok: false, status: 408, statusText: "Aborted" }; + } else { + return { ok: false, status: 500, statusText: error.message }; + } + } else { + return { ok: false, status: 500, statusText: "Unknown" }; + } + } +} + +export async function getStatuses( + items: TData[], + timeout: number, +): Promise> { + const [fulfilled] = await promiseAllSettledUnion( + items.map(async (item) => ({ + ...item, + _meta: await getStatus(item.url, timeout), + })), + ); + return fulfilled; +} diff --git a/app/routes/bookmarks.$bookmarkId._index.tsx b/app/routes/bookmarks.$bookmarkId._index.tsx index c8d439a..6b78690 100644 --- a/app/routes/bookmarks.$bookmarkId._index.tsx +++ b/app/routes/bookmarks.$bookmarkId._index.tsx @@ -1,14 +1,9 @@ -import { conform } from "@conform-to/react"; -import type { - ActionFunctionArgs, - LoaderFunctionArgs, - MetaFunction, -} from "@remix-run/node"; +import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; import { json } from "@remix-run/node"; import { useLoaderData, useLocation } from "@remix-run/react"; import { ButtonDelete } from "~/components/button-delete"; +import { ButtonFavorite } from "~/components/button-favorite"; import { GeneralErrorBoundary, MainError } from "~/components/error-boundary"; -import { Favorite } from "~/components/favorite"; import { Main } from "~/components/main"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; @@ -19,14 +14,8 @@ import { H1 } from "~/components/ui/h1"; import { H2 } from "~/components/ui/h2"; import { Icon } from "~/components/ui/icon"; import { LinkButton } from "~/components/ui/link-button"; -import { - deleteBookmark, - favoriteBookmark, - getBookmark, -} from "~/models/bookmark.server"; +import { getBookmark } from "~/models/bookmark.server"; import { mapWithFaviconSrc } from "~/models/favicon.server"; -import { requireUserId } from "~/utils/auth.server"; -import { FavoriteBookmarkFormSchema } from "~/utils/bookmark-validation"; import { generateSocialMeta } from "~/utils/meta"; import { asyncShare, @@ -34,7 +23,6 @@ import { invariant, invariantResponse, } from "~/utils/misc"; -import { redirectWithToast } from "~/utils/toast.server"; import { USER_LOGIN_ROUTE } from "~/utils/user"; export async function loader({ params }: LoaderFunctionArgs) { @@ -50,35 +38,6 @@ export async function loader({ params }: LoaderFunctionArgs) { return json({ bookmark }); } -export async function action({ params, request }: ActionFunctionArgs) { - const userId = await requireUserId(request); - - invariant(params["bookmarkId"], "bookmarkId not found"); - const { bookmarkId: id } = params; - - const formData = await request.formData(); - const intent = formData.get(conform.INTENT); - - if (intent === "favorite") { - const formFields = Object.fromEntries(formData.entries()); - const submission = FavoriteBookmarkFormSchema.safeParse(formFields); - if (submission.success) { - const { favorite = null } = submission.data; - await favoriteBookmark({ id, favorite, userId }); - } - } - - if (intent === "delete") { - await deleteBookmark({ id, userId }); - return redirectWithToast("/bookmarks", { - type: "success", - description: "Bookmark deleted.", - }); - } - - return null; -} - export const meta: MetaFunction = ({ data }) => { if (!data?.bookmark.url) { return [{ title: "404: Bookmark Not Found" }]; @@ -200,9 +159,10 @@ export default function BookmarkDetailPage() {
    Favorite
    -
    @@ -218,9 +178,10 @@ export default function BookmarkDetailPage() { Edit bookmark {" "} diff --git a/app/routes/bookmarks.$bookmarkId.edit.tsx b/app/routes/bookmarks.$bookmarkId.edit.tsx index 1459730..8aa6cd3 100644 --- a/app/routes/bookmarks.$bookmarkId.edit.tsx +++ b/app/routes/bookmarks.$bookmarkId.edit.tsx @@ -33,13 +33,17 @@ import { LinkButton } from "~/components/ui/link-button"; import { Textarea } from "~/components/ui/textarea"; import { deleteBookmark, + favoriteBookmark, getBookmark, getBookmarkByUrl, updateBookmark, } from "~/models/bookmark.server"; import { getTags } from "~/models/tag.server"; import { requireUserId } from "~/utils/auth.server"; -import { toUpdateBookmarkFormSchema } from "~/utils/bookmark-validation"; +import { + FavoriteBookmarkFormSchema, + toUpdateBookmarkFormSchema, +} from "~/utils/bookmark-validation"; import { formatMetaTitle, getFieldError, @@ -72,6 +76,15 @@ export const action = async ({ params, request }: ActionFunctionArgs) => { const formData = await request.formData(); const intent = formData.get(conform.INTENT); + if (intent === "favorite") { + const formFields = Object.fromEntries(formData.entries()); + const submission = FavoriteBookmarkFormSchema.safeParse(formFields); + if (submission.success) { + const { favorite = null } = submission.data; + await favoriteBookmark({ id, favorite, userId }); + } + } + if (intent === "delete") { await deleteBookmark({ id, userId }); return redirectWithToast("/bookmarks", { @@ -311,7 +324,12 @@ export default function EditBookmarkPage() { Update bookmark {" "} - + ); diff --git a/app/routes/bookmarks._index.tsx b/app/routes/bookmarks._index.tsx index 35a2a08..beb7c02 100644 --- a/app/routes/bookmarks._index.tsx +++ b/app/routes/bookmarks._index.tsx @@ -1,10 +1,6 @@ import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; import { json } from "@remix-run/node"; import { useLoaderData, useNavigation } from "@remix-run/react"; -import { - BookmarksTable, - bookmarksTableColumns, -} from "~/components/bookmarks-table"; import { GeneralErrorBoundary } from "~/components/error-boundary"; import { Main } from "~/components/main"; import { @@ -13,6 +9,10 @@ import { } from "~/components/pagination"; import { SearchForm } from "~/components/search-form"; import { SearchHelp } from "~/components/search-help"; +import { + TableBookmarks, + columnsTableBookmarks, +} from "~/components/table-bookmarks"; import { Badge } from "~/components/ui/badge"; import { H1 } from "~/components/ui/h1"; import { Icon } from "~/components/ui/icon"; @@ -105,8 +105,17 @@ export default function BookmarksIndexPage() { Bookmarks {loaderData.count} - - + + + Bookmarks status + + Import bookmarks @@ -154,10 +163,8 @@ export default function BookmarksIndexPage() { {loaderData.hasData ? ( - ) : null} diff --git a/app/routes/bookmarks.offset.tsx b/app/routes/bookmarks.offset.tsx index 9e708ea..0b8521e 100644 --- a/app/routes/bookmarks.offset.tsx +++ b/app/routes/bookmarks.offset.tsx @@ -1,12 +1,6 @@ -// TODO: remove route when pagination is finalized. - import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; import { json } from "@remix-run/node"; import { useLoaderData, useNavigation } from "@remix-run/react"; -import { - BookmarksTable, - bookmarksTableColumns, -} from "~/components/bookmarks-table"; import { GeneralErrorBoundary } from "~/components/error-boundary"; import { Main } from "~/components/main"; import { @@ -15,6 +9,10 @@ import { } from "~/components/pagination"; import { SearchForm } from "~/components/search-form"; import { SearchHelp } from "~/components/search-help"; +import { + TableBookmarks, + columnsTableBookmarks, +} from "~/components/table-bookmarks"; import { Badge } from "~/components/ui/badge"; import { H1 } from "~/components/ui/h1"; import { Icon } from "~/components/ui/icon"; @@ -149,10 +147,8 @@ export default function BookmarksIndexPage() { {loaderData.hasData ? ( - ) : null} @@ -163,7 +159,6 @@ export default function BookmarksIndexPage() { {loaderData.fields.map(([name, value]) => ( ))} - el._meta.ok).length; + const dataNotOkCount = Math.abs(data.length - dataOkCount); + + const fields = getOffsetPaginationFieldEntries({ + searchParams: url.searchParams, + take, + }); + const hasData = data.length > 0; + const hasPagination = count > take; + + return json({ + count, + data, + dataOkCount, + dataNotOkCount, + fields, + hasData, + hasPagination, + skip, + take, + }); +} + +export default function BookmarksStatusPage() { + const loaderData = useLoaderData(); + const navigation = useNavigation(); + const isPending = navigation.state !== "idle"; + + return ( +
    +
    +

    + 5 + ? "text-pink-500" + : loaderData.dataNotOkCount > 0 + ? "text-yellow-500" + : "text-lime-500" + } + type={ + loaderData.dataNotOkCount > 0 ? "shield-alert" : "shield-check" + } + /> + Bookmarks Status {loaderData.data.length} +

    + +
    + + + + {loaderData.hasData ? ( + + ) : null} + + {loaderData.hasPagination ? ( + +
    + {loaderData.fields.map(([name, value]) => ( + + ))} + +
    +
    + ) : null} +
    + ); +} diff --git a/app/routes/tags.$tagId._index.tsx b/app/routes/tags.$tagId._index.tsx index 73de4e8..9b9f769 100644 --- a/app/routes/tags.$tagId._index.tsx +++ b/app/routes/tags.$tagId._index.tsx @@ -1,9 +1,4 @@ -import { conform } from "@conform-to/react"; -import type { - ActionFunctionArgs, - LoaderFunctionArgs, - MetaFunction, -} from "@remix-run/node"; +import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; import { json } from "@remix-run/node"; import { useLoaderData, useLocation } from "@remix-run/react"; import { ButtonDelete } from "~/components/button-delete"; @@ -16,11 +11,9 @@ import { H1 } from "~/components/ui/h1"; import { H2 } from "~/components/ui/h2"; import { Icon } from "~/components/ui/icon"; import { LinkButton } from "~/components/ui/link-button"; -import { deleteTag, getTag } from "~/models/tag.server"; -import { requireUserId } from "~/utils/auth.server"; +import { getTag } from "~/models/tag.server"; import { generateSocialMeta } from "~/utils/meta"; import { formatMetaTitle, invariant, invariantResponse } from "~/utils/misc"; -import { redirectWithToast } from "~/utils/toast.server"; import { USER_LOGIN_ROUTE } from "~/utils/user"; export async function loader({ params }: LoaderFunctionArgs) { @@ -35,25 +28,6 @@ export async function loader({ params }: LoaderFunctionArgs) { return json({ tag }); } -export async function action({ params, request }: ActionFunctionArgs) { - const userId = await requireUserId(request); - invariant(params["tagId"], "tagId not found"); - - const formData = await request.formData(); - const intent = formData.get(conform.INTENT); - - if (intent === "delete") { - const { tagId: id } = params; - await deleteTag({ id, userId }); - return redirectWithToast("/tags", { - type: "success", - description: "Tag deleted.", - }); - } - - return null; -} - export const meta: MetaFunction = ({ data }) => { if (!data?.tag.name) { return [{ title: "404: Tag Not Found" }]; @@ -137,7 +111,12 @@ export default function TagDetailPage() { Merge tag
    {" "} - + diff --git a/app/routes/tags.$tagId.edit.tsx b/app/routes/tags.$tagId.edit.tsx index 0a6b0cf..90f1097 100644 --- a/app/routes/tags.$tagId.edit.tsx +++ b/app/routes/tags.$tagId.edit.tsx @@ -182,7 +182,12 @@ export default function EditTagPage() { Update tag {" "} - + ); diff --git a/app/utils/bookmark-exports.server.ts b/app/utils/bookmark-exports.server.ts index 388ff9d..32475d3 100644 --- a/app/utils/bookmark-exports.server.ts +++ b/app/utils/bookmark-exports.server.ts @@ -20,7 +20,7 @@ export function createExportAction(fileExtension: BookmarkExportFileExtension) { await requireUserId(request); const formData = await request.formData(); - const selectedIds = String(formData.get("selected-ids") ?? "") + const idsSelected = String(formData.get("ids-selected") ?? "") .split(",") .filter(Boolean); @@ -28,8 +28,8 @@ export function createExportAction(fileExtension: BookmarkExportFileExtension) { return exportResponse({ data: - selectedIds.length > 0 - ? bookmarks.filter((el) => selectedIds.includes(el.id)) + idsSelected.length > 0 + ? bookmarks.filter((el) => idsSelected.includes(el.id)) : bookmarks, fileExtension, }); diff --git a/tests/e2e/bookmarks.$bookmarkId._index.test.ts b/tests/e2e/bookmarks.$bookmarkId._index.test.ts index 9a7ae84..9ff49df 100644 --- a/tests/e2e/bookmarks.$bookmarkId._index.test.ts +++ b/tests/e2e/bookmarks.$bookmarkId._index.test.ts @@ -1,4 +1,10 @@ -import { expect, login, logout, test } from "../utils/playwright-test-utils"; +import { + encodeUrl, + expect, + login, + logout, + test, +} from "../utils/playwright-test-utils"; test.describe("Unauthenticated", () => { test.beforeEach(async ({ page }) => { @@ -25,9 +31,11 @@ test.describe("Unauthenticated", () => { }); test("User can NOT (un)favorite a bookmark", async ({ page }) => { - await page.getByRole("link", { name: "Unfavorite", exact: true }).click(); + await page.getByRole("button", { name: "Unfavorite", exact: true }).click(); - await expect(page).toHaveURL("/login?redirectTo=/bookmarks/bid2"); + await expect(page).toHaveURL( + encodeUrl({ page, url: "/login?redirectTo=/bookmarks/bid2/edit" }), + ); }); test("User can NOT edit a bookmark", async ({ page }) => { @@ -40,10 +48,17 @@ test.describe("Unauthenticated", () => { test("User can NOT delete a bookmark", async ({ page }) => { await page - .getByRole("link", { name: "Delete bookmark", exact: true }) + .getByRole("button", { name: "Delete bookmark", exact: true }) .click(); + await page + .getByRole("button", { name: "Confirm delete bookmark", exact: true }) + .click(); + + // await expect(page).toHaveURL("/login?redirectTo=/bookmarks/bid2/edit"); - await expect(page).toHaveURL("/login?redirectTo=/bookmarks/bid2"); + await expect(page).toHaveURL( + encodeUrl({ page, url: "/login?redirectTo=/bookmarks/bid2/edit" }), + ); }); }); diff --git a/tests/e2e/bookmarks.$bookmarkId.edit.test.ts b/tests/e2e/bookmarks.$bookmarkId.edit.test.ts index 7f79ffc..e8fd242 100644 --- a/tests/e2e/bookmarks.$bookmarkId.edit.test.ts +++ b/tests/e2e/bookmarks.$bookmarkId.edit.test.ts @@ -1,5 +1,5 @@ import { - encodeUrlRedirectTo, + encodeUrl, expect, login, logout, @@ -13,10 +13,7 @@ test.describe("Unauthenticated", () => { test("User can NOT view the page", async ({ page }) => { await expect(page).toHaveURL( - encodeUrlRedirectTo({ - page, - url: "/login?redirectTo=/bookmarks/bid2/edit", - }), + encodeUrl({ page, url: "/login?redirectTo=/bookmarks/bid2/edit" }), ); }); }); diff --git a/tests/e2e/bookmarks._index.test.ts b/tests/e2e/bookmarks._index.test.ts index ff8068e..bd0a2df 100644 --- a/tests/e2e/bookmarks._index.test.ts +++ b/tests/e2e/bookmarks._index.test.ts @@ -1,4 +1,10 @@ -import { expect, login, logout, test } from "../utils/playwright-test-utils"; +import { + encodeUrl, + expect, + login, + logout, + test, +} from "../utils/playwright-test-utils"; test.describe("Unauthenticated", () => { test.beforeEach(async ({ page }) => { @@ -159,11 +165,13 @@ test.describe("Unauthenticated", () => { test("User can NOT (un)favorite a bookmark", async ({ page }) => { await page - .getByRole("link", { name: "Unfavorite", exact: true }) + .getByRole("button", { name: "Unfavorite bookmark", exact: true }) .first() .click(); - await expect(page).toHaveURL("/login?redirectTo=/bookmarks"); + await expect(page).toHaveURL( + encodeUrl({ page, url: "/login?redirectTo=/bookmarks/bid0/edit" }), + ); }); }); @@ -188,7 +196,9 @@ test.describe("Authenticated", () => { test("User can (un)favorite a bookmark", async ({ page }) => { await expect( - page.getByRole("button", { name: "Unfavorite", exact: true }).first(), + page + .getByRole("button", { name: "Unfavorite bookmark", exact: true }) + .first(), ).toBeVisible(); }); }); diff --git a/tests/e2e/bookmarks.new.test.ts b/tests/e2e/bookmarks.new.test.ts index b1b0551..451c060 100644 --- a/tests/e2e/bookmarks.new.test.ts +++ b/tests/e2e/bookmarks.new.test.ts @@ -1,5 +1,5 @@ import { - encodeUrlRedirectTo, + encodeUrl, expect, login, logout, @@ -13,7 +13,7 @@ test.describe("Unauthenticated", () => { test("User can NOT view the page", async ({ page }) => { await expect(page).toHaveURL( - encodeUrlRedirectTo({ page, url: "/login?redirectTo=/bookmarks/new" }), + encodeUrl({ page, url: "/login?redirectTo=/bookmarks/new" }), ); }); }); diff --git a/tests/e2e/tags.$tagId._index.test.ts b/tests/e2e/tags.$tagId._index.test.ts index 05cee3f..0150fa6 100644 --- a/tests/e2e/tags.$tagId._index.test.ts +++ b/tests/e2e/tags.$tagId._index.test.ts @@ -1,4 +1,10 @@ -import { expect, login, logout, test } from "../utils/playwright-test-utils"; +import { + encodeUrl, + expect, + login, + logout, + test, +} from "../utils/playwright-test-utils"; test.describe("Unauthenticated", () => { test.beforeEach(async ({ page }) => { @@ -10,7 +16,7 @@ test.describe("Unauthenticated", () => { }); test("User can search bookmarks by tag", async ({ page }) => { - await page.getByText("Bookmarks(6)").click(); + await page.getByText("Bookmarks6").click(); await expect(page).toHaveURL("/bookmarks?searchValue=tag1&searchKey=tags"); }); @@ -40,9 +46,14 @@ test.describe("Unauthenticated", () => { }); test("User can NOT delete a tag", async ({ page }) => { - await page.getByRole("link", { name: "Delete tag", exact: true }).click(); - - await expect(page).toHaveURL("/login?redirectTo=/tags/tid0"); + await page.getByRole("button", { name: "Delete tag", exact: true }).click(); + await page + .getByRole("button", { name: "Confirm delete tag", exact: true }) + .click(); + + await expect(page).toHaveURL( + encodeUrl({ page, url: "/login?redirectTo=/tags/tid0/edit" }), + ); }); }); diff --git a/tests/e2e/tags.$tagId.edit.test.ts b/tests/e2e/tags.$tagId.edit.test.ts index 65e21ef..fdc18f4 100644 --- a/tests/e2e/tags.$tagId.edit.test.ts +++ b/tests/e2e/tags.$tagId.edit.test.ts @@ -1,5 +1,5 @@ import { - encodeUrlRedirectTo, + encodeUrl, expect, login, logout, @@ -13,10 +13,7 @@ test.describe("Unauthenticated", () => { test("User can NOT view the page", async ({ page }) => { await expect(page).toHaveURL( - encodeUrlRedirectTo({ - page, - url: "/login?redirectTo=/tags/tid0/edit", - }), + encodeUrl({ page, url: "/login?redirectTo=/tags/tid0/edit" }), ); }); }); diff --git a/tests/e2e/tags.$tagId.merge.test.ts b/tests/e2e/tags.$tagId.merge.test.ts index 513338e..9d284e7 100644 --- a/tests/e2e/tags.$tagId.merge.test.ts +++ b/tests/e2e/tags.$tagId.merge.test.ts @@ -1,5 +1,5 @@ import { - encodeUrlRedirectTo, + encodeUrl, expect, login, logout, @@ -13,10 +13,7 @@ test.describe("Unauthenticated", () => { test("User can NOT view the page", async ({ page }) => { await expect(page).toHaveURL( - encodeUrlRedirectTo({ - page, - url: "/login?redirectTo=/tags/tid0/merge", - }), + encodeUrl({ page, url: "/login?redirectTo=/tags/tid0/merge" }), ); }); }); diff --git a/tests/e2e/tags.$tagId.split.test.ts b/tests/e2e/tags.$tagId.split.test.ts index 1c0cc5a..e18f890 100644 --- a/tests/e2e/tags.$tagId.split.test.ts +++ b/tests/e2e/tags.$tagId.split.test.ts @@ -1,5 +1,5 @@ import { - encodeUrlRedirectTo, + encodeUrl, expect, login, logout, @@ -13,10 +13,7 @@ test.describe("Unauthenticated", () => { test("User can NOT view the page", async ({ page }) => { await expect(page).toHaveURL( - encodeUrlRedirectTo({ - page, - url: "/login?redirectTo=/tags/tid0/split", - }), + encodeUrl({ page, url: "/login?redirectTo=/tags/tid0/split" }), ); }); }); diff --git a/tests/e2e/tags._index.test.ts b/tests/e2e/tags._index.test.ts index 6f9c026..c9de7b5 100644 --- a/tests/e2e/tags._index.test.ts +++ b/tests/e2e/tags._index.test.ts @@ -10,7 +10,7 @@ test.describe("Unauthenticated", () => { }); test("User can view tags", async ({ page }) => { - const tagNameRegex = /^[a-zA-Z0-9-.\s]+\s\(\s\d+\s\)/; + const tagNameRegex = /^[a-zA-Z0-9-.\s]+\s\d+/; const tags = await page.getByRole("link", { name: tagNameRegex }).all(); expect(tags.length).toBe(11); @@ -19,7 +19,7 @@ test.describe("Unauthenticated", () => { test("User can view tags sorted by Name", async ({ page }) => { await expect( page.getByText( - "taaaaaaaaaaaaag that is exactly 45 characters (0)tag1 (6)tag10 (0)tag2 (5)tag3 (4)tag4 (3)tag5 (2)tag6 (1)tag7 (0)tag8 (0)tag9 (0)", + "taaaaaaaaaaaaag that is exactly 45 characters 0tag1 6tag10 0tag2 5tag3 4tag4 3tag5 2tag6 1tag7 0tag8 0tag9 0", ), ).toBeVisible(); }); @@ -29,13 +29,13 @@ test.describe("Unauthenticated", () => { await expect( page.getByText( - "tag1 (6)tag2 (5)tag3 (4)tag4 (3)tag5 (2)tag6 (1)taaaaaaaaaaaaag that is exactly 45 characters (0)tag10 (0)tag7 (0)tag8 (0)tag9 (0)", + "tag1 6tag2 5tag3 4tag4 3tag5 2tag6 1taaaaaaaaaaaaag that is exactly 45 characters 0tag10 0tag7 0tag8 0tag9 0", ), ).toBeVisible(); }); test("User can go to a tag's detail page", async ({ page }) => { - await page.getByRole("link", { name: "tag1 ( 6 )", exact: true }).click(); + await page.getByRole("link", { name: "tag1 6", exact: true }).click(); await expect(page).toHaveURL("/tags/tid0"); }); diff --git a/tests/e2e/tags.new.test.ts b/tests/e2e/tags.new.test.ts index b1ecf2a..949ed11 100644 --- a/tests/e2e/tags.new.test.ts +++ b/tests/e2e/tags.new.test.ts @@ -1,5 +1,5 @@ import { - encodeUrlRedirectTo, + encodeUrl, expect, login, logout, @@ -13,7 +13,7 @@ test.describe("Unauthenticated", () => { test("User can NOT view the page", async ({ page }) => { await expect(page).toHaveURL( - encodeUrlRedirectTo({ page, url: "/login?redirectTo=/tags/new" }), + encodeUrl({ page, url: "/login?redirectTo=/tags/new" }), ); }); }); diff --git a/tests/utils/playwright-test-utils.ts b/tests/utils/playwright-test-utils.ts index ee7b2d6..78e1a9c 100644 --- a/tests/utils/playwright-test-utils.ts +++ b/tests/utils/playwright-test-utils.ts @@ -29,18 +29,9 @@ export async function logout({ page }: { page: Page }) { .press("Enter"); } -export function encodeUrlRedirectTo({ - page, - url, -}: { - page: Page; - url: string; -}) { +export function encodeUrl({ page, url }: { page: Page; url: string }) { const { pathname, searchParams } = new URL(url, page.url()); - const encodedRedirectTo = encodeURIComponent( - searchParams.get("redirectTo") || "/", - ); - return `${pathname}?redirectTo=${encodedRedirectTo}`; + return [pathname, searchParams.toString()].join("?"); } const test = base.extend({});