Skip to content

Commit

Permalink
Use a table for the grid and list overview of files
Browse files Browse the repository at this point in the history
Our card listingtable could not easily support showing columns for size,
modified and name. But instead we can use a Table with CSS to accommodate
both views. The added benefits of dropping the card, showing the icons
via CSS outweigh using a Card or a per view specific component.

The ListingTable is not used as it has a <tbody> for every row which is
simply unneeded and we already require our own CSS customization for
styling the Table to support both views.

Co-Authored-By: Garrett LeSage <[email protected]>
  • Loading branch information
jelly and garrett committed Jun 4, 2024
1 parent 05e6a31 commit 0bad527
Show file tree
Hide file tree
Showing 6 changed files with 410 additions and 216 deletions.
76 changes: 0 additions & 76 deletions src/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -63,78 +63,6 @@ button.breadcrumb-edit-cancel-button {
grid-template-columns: repeat(auto-fill, minmax(var(--pf-v5-l-gallery--GridTemplateColumns--max), 1fr));
}

.directory-item, .file-item {
// Align icon and text in icon mode
.pf-v5-l-gallery & .pf-v5-c-card__header-main {
flex-direction: column;
align-items: center;
text-align: center;
gap: 0;
}

// Align icon and text in list mode
.ct-table & .pf-v5-c-card__header-main {
justify-content: start !important;
gap: var(--pf-v5-global--spacer--sm);
}

// Limit to 3 vertical lines and add ellipsis at the end when needed.
// (Sadly, there's no way to truncate in the middle, except via a PF
// component... but that doesn't allow wrapping and clamping line height.)
.pf-v5-c-card__title-text {
// Yes, all browsers support line-clamp with the webkit prefix. No
// browser supports it unprefixed. All browsers require a display of
// -webkit-box and -webkit-box-orient as well. It's one of the few
// prefixed attributes that's so well supported and still needed.
display: -webkit-box; /* stylelint-disable-line value-no-vendor-prefix */
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: anywhere;
// Wrapping should be more balanced (new; not supported everywhere yet)
text-wrap: balance;
}
}

.directory-item {
--icon-color: var(--pf-v5-global--link--Color--light);

&:is(:hover,.pf-m-selected) {
--icon-color: var(--pf-v5-global--link--Color);
}

.pf-v5-theme-dark & {
--icon-color: var(--pf-v5-global--link--Color--dark);
}

.pf-v5-theme-dark &:is(:hover,.pf-m-selected) {
--icon-color: var(--pf-v5-global--link--Color--dark--hover);
}
}

.file-item {
--icon-color: var(--pf-v5-global--palette--black-400);

&:is(:hover,.pf-m-selected) {
--icon-color: var(--pf-v5-global--palette--black-500);
}

.pf-v5-theme-dark & {
--icon-color: var(--pf-v5-global--palette--black-400);
}

.pf-v5-theme-dark &:is(:hover,.pf-m-selected) {
--icon-color: var(--pf-v5-global--palette--black-300);
}
}

.directory-item, .file-item {
svg > path {
fill: var(--icon-color);
}
}

.sidebar-card {
background: inherit;
box-shadow: none;
Expand All @@ -149,10 +77,6 @@ button.breadcrumb-edit-cancel-button {
margin-block-end: 1rem;
}

.item-button {
color: var(--pf-v5-global--Color--200);
}

// Improve header layout and wrap header text
.sidebar-panel {
.pf-v5-c-card__header-main {
Expand Down
176 changes: 93 additions & 83 deletions src/files-card-body.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,26 @@

import React, { useCallback, useEffect, useMemo, useState, useRef } from "react";
import {
Card, CardBody,
Flex,
Gallery,
Icon,
CardTitle, Spinner, CardHeader,
MenuItem, MenuList,
Spinner,
MenuItem,
MenuList,
Divider,
} from "@patternfly/react-core";
import { FileIcon, FolderIcon } from "@patternfly/react-icons";
import { Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table';

import cockpit from "cockpit";
import { useDialogs } from "dialogs.jsx";
import { ListingTable } from "cockpit-components-table.jsx";
import { EmptyStatePanel } from "cockpit-components-empty-state.jsx";

import * as timeformat from "timeformat";
import { ContextMenu } from "cockpit-components-context-menu.jsx";
import { fileActions, ConfirmDeletionDialog } from "./fileActions.jsx";
import { filterColumnMapping, filterColumns } from "./header";
import { useFilesContext } from "./app";

import "./files-card-body.scss";

const _ = cockpit.gettext;

const compare = (sortBy) => {
Expand Down Expand Up @@ -73,6 +74,10 @@ const compare = (sortBy) => {
? -1
: 1)
: compareFileType(a, b);
case "largest_size":
return (a, b) => b.size - a.size;
case "smallest_size":
return (a, b) => a.size - b.size;
default:
break;
}
Expand Down Expand Up @@ -109,6 +114,7 @@ export const FilesCardBody = ({
selected,
setSelected,
sortBy,
setSortBy,
loadingFiles,
clipboard,
setClipboard,
Expand All @@ -127,7 +133,7 @@ export const FilesCardBody = ({
const folderViewRef = React.useRef();

function calculateBoxPerRow () {
const boxes = document.querySelectorAll(".item-button");
const boxes = document.querySelectorAll(".fileview tbody > tr");
if (boxes.length > 1) {
let i = 0;
const total = boxes.length;
Expand Down Expand Up @@ -156,14 +162,16 @@ export const FilesCardBody = ({
let folderViewElem = null;

const resetSelected = e => {
if (e.target.id === "folder-view" || e.target.id === "files-card-body") {
if (e.target.id === "folder-view" || e.target.id === "files-card-parent" ||
(e.target.parentElement && e.target.parentElement.id === "folder-view")) {
if (selected.length !== 0) {
setSelected([]);
}
}
};

const handleDoubleClick = (ev) => {
ev.preventDefault();
const name = getFilenameForEvent(ev);
const file = sortedFiles?.find(file => file.name === name);
if (!file)
Expand All @@ -177,6 +185,7 @@ export const FilesCardBody = ({
};

const handleClick = (ev) => {
ev.preventDefault();
const name = getFilenameForEvent(ev);
const file = sortedFiles?.find(file => file.name === name);
if (!file) {
Expand Down Expand Up @@ -342,94 +351,95 @@ export const FilesCardBody = ({
</ContextMenu>
);

const sortColumn = (columnIndex) => ({
sortBy: {
index: filterColumnMapping[sortBy][0],
direction: filterColumnMapping[sortBy][1],
},
onSort: (_event, index, direction) => {
setSortBy(filterColumns[index][direction].itemId);
},
columnIndex,
});

return (
<div id={files_parent_id}>
<div id={files_parent_id} ref={folderViewRef}>
{contextMenu}
<div
ref={folderViewRef}
>
{sortedFiles.length === 0 &&
{sortedFiles.length === 0 &&
<EmptyStatePanel
paragraph={currentFilter ? _("No matching results") : _("Directory is empty")}
/>}
{isGrid &&
<CardBody id="files-card-body">
<Gallery id="folder-view">
{sortedFiles.map(file =>
<Item
file={file}
key={file.name}
isSelected={!!selected.find(s => s.name === file.name)}
isGrid={isGrid}
/>)}
</Gallery>
</CardBody>}
{!isGrid &&
<ListingTable
id="folder-view"
className="pf-m-no-border-rows"
variant="compact"
columns={[_("Name")]}
rows={sortedFiles.map(file => ({
columns: [
{
title: (
<Item
file={file}
key={file.name}
isSelected={!!selected.find(s => s.name === file.name)}
isGrid={isGrid}
/>)
}
]
}))}
/>}
</div>
{sortedFiles.length !== 0 &&
<Table
id="folder-view"
className={`pf-m-no-border-rows fileview ${isGrid ? 'view-grid' : 'view-details'}`}
variant="compact"
>
<Thead>
<Tr>
<Th sort={sortColumn(0)} className="col-name">{_("Name")}</Th>
<Th sort={sortColumn(1)} className="col-size">{_("Size")}</Th>
<Th
sort={sortColumn(2)} className="col-date"
modifier="nowrap"
>{_("Modified")}
</Th>
</Tr>
</Thead>
<Tbody>
{sortedFiles.map((file, rowIndex) =>
<Row
key={rowIndex}
file={file}
isSelected={selected.some(s => s.name === file.name)}
/>)}
</Tbody>
</Table>}
</div>
);
};

// Memoize the Item component as rendering thousands of them on each render of parent component is costly.
const Item = React.memo(function Item({ file, isSelected, isGrid }) {
function getFileType(file) {
if (file.type === "dir") {
return "directory-item";
} else if (file.type === "lnk" && file?.to === "dir") {
return "directory-item";
} else {
return "file-item";
}
const getFileType = (file) => {
if (file.type === "dir" || file.to === "dir") {
return "folder";
} else {
return "file";
}
};

// Memoize the Item component as rendering thousands of them on each render of parent component is costly.
const Row = React.memo(function Item({ file, isSelected }) {
const fileType = getFileType(file);
let className = fileType;
if (isSelected)
className += " row-selected";
if (file.type === "lnk")
className += " symlink";

return (
<Card
className={"item-button " + getFileType(file)}
<Tr
className={className}
data-item={file.name}
id={"card-item-" + file.name + file.type}
isClickable isCompact
isPlain
isSelected={isSelected}
>
<CardHeader
selectableActions={{
name: file.name,
selectableActionAriaLabelledby: "card-item-" + file.name + file.type,
selectableActionId: "card-item-" + file.name + file.type + "-selectable-action",
}}
<Td
className="item-name"
dataLabel={fileType}
>
<Icon
size={isGrid
? "xl"
: "lg"} isInline
>
{file.type === "dir" || file.to === "dir"
? <FolderIcon />
: <FileIcon />}
</Icon>
<CardTitle>
{file.name}
</CardTitle>
</CardHeader>
</Card>
<a href="#">{file.name}</a>
</Td>
<Td
className="item-size"
dataLabel="size"
>
{cockpit.format_bytes(file.size)}
</Td>
<Td
className="item-date"
dataLabel="date"
modifier="nowrap"
>
{timeformat.dateTime(file.mtime * 1000)}
</Td>
</Tr>
);
});
Loading

0 comments on commit 0bad527

Please sign in to comment.