Sort files chronologically + infinite scroll (#535)

This commit is contained in:
Montassar Ghanmy
2024-07-15 22:56:34 +01:00
committed by GitHub
parent 585da88b0d
commit e76da87916
25 changed files with 719 additions and 153 deletions
@@ -264,12 +264,22 @@ export class MongoConnector extends AbstractConnector<MongoConnectionOptions> {
options, options,
); );
const sort: any = {}; let sort: any = {};
for (const key of entityDefinition.options.primaryKey.slice(1)) { for (const key of entityDefinition.options.primaryKey.slice(1)) {
const defaultOrder = const defaultOrder =
(columnsDefinition[key as string].options.order || "ASC") === "ASC" ? 1 : -1; (columnsDefinition[key as string].options.order || "ASC") === "ASC" ? 1 : -1;
sort[key as string] = (options?.pagination?.reversed ? -1 : 1) * defaultOrder; sort[key as string] = (options?.pagination?.reversed ? -1 : 1) * defaultOrder;
} }
if (options?.sort) {
sort = options.sort
? Object.fromEntries(
Object.entries(options.sort).map(([field, direction]) => [
field,
direction === "asc" ? 1 : -1,
]),
)
: {};
}
logger.debug(`services.database.orm.mongodb.find - Query: ${JSON.stringify(query)}`); logger.debug(`services.database.orm.mongodb.find - Query: ${JSON.stringify(query)}`);
@@ -277,7 +287,11 @@ export class MongoConnector extends AbstractConnector<MongoConnectionOptions> {
.find(query) .find(query)
.sort(sort) .sort(sort)
.skip(Math.max(0, parseInt(options.pagination.page_token || "0"))) .skip(Math.max(0, parseInt(options.pagination.page_token || "0")))
.limit(Math.max(0, parseInt(options.pagination.limitStr || "100"))); .limit(Math.max(0, parseInt(options.pagination.limitStr || "100")))
.collation({
locale: "en_US",
numericOrdering: true,
});
const entities: Table[] = []; const entities: Table[] = [];
while (await cursor.hasNext()) { while (await cursor.hasNext()) {
@@ -88,13 +88,20 @@ export class PostgresQueryBuilder {
if (whereClause && whereClause.endsWith("AND ")) whereClause = whereClause.slice(0, -4); if (whereClause && whereClause.endsWith("AND ")) whereClause = whereClause.slice(0, -4);
// ==== ORDER BY ===== // ==== ORDER BY =====
const orderByClause = `${entityDefinition.options.primaryKey let orderByClause = `${entityDefinition.options.primaryKey
.slice(1) .slice(1)
.map( .map(
(key: string) => (key: string) =>
`${key} ${(columnsDefinition[key].options.order || "ASC") === "ASC" ? "DESC" : "ASC"}`, `${key} ${(columnsDefinition[key].options.order || "ASC") === "ASC" ? "DESC" : "ASC"}`,
)}`; )}`;
// ==== ORDER BY CUSTOM COLUMN =====
if (options?.sort) {
orderByClause = Object.keys(options.sort)
.map(key => `${key} ${options.sort[key].toUpperCase()}`)
.join(", ");
}
// ==== PAGING ===== // ==== PAGING =====
let limit = 100; let limit = 100;
let offset = 0; let offset = 0;
@@ -22,6 +22,12 @@ export type inType = [string, Array<any>];
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type likeType = [string, any]; type likeType = [string, any];
type SortDirection = "asc" | "desc";
type SortOption = {
[field: string]: SortDirection;
};
export type FindOptions = { export type FindOptions = {
pagination?: Pagination; pagination?: Pagination;
$lt?: comparisonType[]; $lt?: comparisonType[];
@@ -34,6 +40,7 @@ export type FindOptions = {
$in?: inType[]; $in?: inType[];
$nin?: inType[]; $nin?: inType[];
$like?: likeType[]; $like?: likeType[];
sort?: SortOption;
}; };
/** /**
@@ -35,14 +35,14 @@ export class ConsoleServiceImpl implements TdriveServiceProvider {
this.consoleOptions = { this.consoleOptions = {
type: type, type: type,
authority: s.authority, authority: s?.authority,
client_id: s.client_id, client_id: s?.client_id,
client_secret: s.client_secret, client_secret: s?.client_secret,
audience: s.audience, audience: s?.audience,
issuer: s.issuer, issuer: s?.issuer,
jwks_uri: s.jwks_uri, jwks_uri: s?.jwks_uri,
redirect_uris: s.redirect_uris, redirect_uris: s?.redirect_uris,
disable_account_creation: s.disable_account_creation, disable_account_creation: s?.disable_account_creation,
}; };
this.consoleOptions.type = type; this.consoleOptions.type = type;
@@ -29,8 +29,10 @@ import {
DriveFileAccessLevel, DriveFileAccessLevel,
DriveItemDetails, DriveItemDetails,
DriveTdriveTab, DriveTdriveTab,
PaginateDocumentBody,
RootType, RootType,
SearchDocumentsOptions, SearchDocumentsOptions,
SortDocumentsBody,
TrashType, TrashType,
} from "../types"; } from "../types";
import { import {
@@ -105,6 +107,8 @@ export class DocumentsService {
browse = async ( browse = async (
id: string, id: string,
options: SearchDocumentsOptions, options: SearchDocumentsOptions,
sort: SortDocumentsBody,
paginate: PaginateDocumentBody,
context: DriveExecutionContext & { public_token?: string }, context: DriveExecutionContext & { public_token?: string },
): Promise<BrowseDetails> => { ): Promise<BrowseDetails> => {
if (isSharedWithMeFolder(id)) { if (isSharedWithMeFolder(id)) {
@@ -112,7 +116,7 @@ export class DocumentsService {
} else { } else {
return { return {
nextPage: null, nextPage: null,
...(await this.get(id, context)), ...(await this.get(id, context, false, sort, paginate)),
}; };
} }
}; };
@@ -154,6 +158,9 @@ export class DocumentsService {
get = async ( get = async (
id: string, id: string,
context: DriveExecutionContext & { public_token?: string }, context: DriveExecutionContext & { public_token?: string },
all?: boolean,
sort?: SortDocumentsBody,
paginate?: PaginateDocumentBody,
): Promise<DriveItemDetails> => { ): Promise<DriveItemDetails> => {
if (!context) { if (!context) {
this.logger.error("invalid context"); this.logger.error("invalid context");
@@ -206,7 +213,26 @@ export class DocumentsService {
) )
).getEntities(); ).getEntities();
//Get children if it is a directory const sortFieldMapping = {
name: "name",
date: "last_modified",
size: "size",
};
let sortField = {};
sortField[sortFieldMapping[sort?.by] || "last_modified"] = sort?.order || "desc";
const dbType = await globalResolver.database.getConnector().getType();
// Initialize pagination
let pagination;
if (paginate) {
const { page, limit } = paginate;
const pageNumber = dbType === "mongodb" ? page : page / limit + 1;
pagination = new Pagination(`${pageNumber}`, `${limit}`, false);
}
let children = isDirectory let children = isDirectory
? ( ? (
await this.repository.find( await this.repository.find(
@@ -229,7 +255,15 @@ export class DocumentsService {
is_in_trash: false, is_in_trash: false,
}), }),
}, },
{}, all
? {}
: {
sort: {
is_directory: "desc",
...sortField,
},
pagination,
},
context, context,
) )
).getEntities() ).getEntities()
@@ -59,6 +59,12 @@ export type SearchDocumentsOptions = {
pagination?: Paginable; pagination?: Paginable;
}; };
export type BrowseDocumentsOptions = {
filter?: SearchDocumentsBody;
sort?: SortDocumentsBody;
paginate?: PaginateDocumentBody;
};
export type SearchDocumentsBody = { export type SearchDocumentsBody = {
search?: string; search?: string;
company_id?: string; company_id?: string;
@@ -73,6 +79,16 @@ export type SearchDocumentsBody = {
fields?: string[]; fields?: string[];
}; };
export type SortDocumentsBody = {
by: string;
order: string;
};
export type PaginateDocumentBody = {
page: number;
limit: number;
};
export type DocumentsMessageQueueRequest = { export type DocumentsMessageQueueRequest = {
item: DriveFile; item: DriveFile;
version: FileVersion; version: FileVersion;
@@ -9,16 +9,19 @@ import { CompanyUserRole, PaginationQueryParameters } from "../../../../utils/ty
import { DriveFile } from "../../entities/drive-file"; import { DriveFile } from "../../entities/drive-file";
import { FileVersion } from "../../entities/file-version"; import { FileVersion } from "../../entities/file-version";
import { import {
BrowseDocumentsOptions,
CompanyExecutionContext, CompanyExecutionContext,
DriveExecutionContext, DriveExecutionContext,
DriveFileAccessLevel, DriveFileAccessLevel,
DriveItemDetails, DriveItemDetails,
DriveTdriveTab, DriveTdriveTab,
ItemRequestParams, ItemRequestParams,
PaginateDocumentBody,
ItemRequestByEditingSessionKeyParams, ItemRequestByEditingSessionKeyParams,
RequestParams, RequestParams,
SearchDocumentsBody, SearchDocumentsBody,
SearchDocumentsOptions, SearchDocumentsOptions,
SortDocumentsBody,
} from "../../types"; } from "../../types";
import { DriveFileDTO } from "../dto/drive-file-dto"; import { DriveFileDTO } from "../dto/drive-file-dto";
import { DriveFileDTOBuilder } from "../../services/drive-file-dto-builder"; import { DriveFileDTOBuilder } from "../../services/drive-file-dto-builder";
@@ -196,7 +199,7 @@ export class DocumentsController {
browse = async ( browse = async (
request: FastifyRequest<{ request: FastifyRequest<{
Params: ItemRequestParams; Params: ItemRequestParams;
Body: SearchDocumentsBody; Body: BrowseDocumentsOptions;
Querystring: PaginationQueryParameters & { public_token?: string }; Querystring: PaginationQueryParameters & { public_token?: string };
}>, }>,
): Promise<DriveItemDetails> => { ): Promise<DriveItemDetails> => {
@@ -204,15 +207,24 @@ export class DocumentsController {
const { id } = request.params; const { id } = request.params;
const options: SearchDocumentsOptions = { const options: SearchDocumentsOptions = {
...request.body, ...request.body.filter,
company_id: request.body.company_id || context.company.id, company_id: request.body.filter?.company_id || context.company.id,
view: DriveFileDTOBuilder.VIEW_SHARED_WITH_ME, view: DriveFileDTOBuilder.VIEW_SHARED_WITH_ME,
onlyDirectlyShared: true, onlyDirectlyShared: true,
onlyUploadedNotByMe: true, onlyUploadedNotByMe: true,
}; };
const sortOptions: SortDocumentsBody = request.body.sort;
const paginateOptions: PaginateDocumentBody = request.body.paginate;
return { return {
...(await globalResolver.services.documents.documents.browse(id, options, context)), ...(await globalResolver.services.documents.documents.browse(
id,
options,
sortOptions,
paginateOptions,
context,
)),
}; };
}; };
@@ -446,11 +458,12 @@ export class DocumentsController {
downloadZip = async ( downloadZip = async (
request: FastifyRequest<{ request: FastifyRequest<{
Params: RequestParams; Params: RequestParams;
Querystring: { token?: string; items: string; public_token?: string }; Querystring: { token?: string; items: string; public_token?: string; is_directory?: boolean };
}>, }>,
reply: FastifyReply, reply: FastifyReply,
): Promise<void> => { ): Promise<void> => {
const context = getDriveExecutionContext(request); const context = getDriveExecutionContext(request);
const isDirectory = request.query.is_directory || false;
let ids = (request.query.items || "").split(","); let ids = (request.query.items || "").split(",");
const token = request.query.token; const token = request.query.token;
@@ -466,6 +479,11 @@ export class DocumentsController {
ids = items.children.map(item => item.id); ids = items.children.map(item => item.id);
} }
if (isDirectory) {
const items = await globalResolver.services.documents.documents.get(ids[0], context, true);
ids = items.children.map(item => item.id);
}
try { try {
const archive = await globalResolver.services.documents.documents.createZip(ids, context); const archive = await globalResolver.services.documents.documents.createZip(ids, context);
@@ -0,0 +1,156 @@
import { describe, beforeAll, afterAll, it, expect } from "@jest/globals";
import { init, TestPlatform } from "../setup";
import UserApi from "../common/user-api";
describe("The Documents Browser Window and API", () => {
let platform: TestPlatform;
let currentUser: UserApi;
beforeAll(async () => {
platform = await init({
services: [
"webserver",
"database",
"applications",
"search",
"storage",
"message-queue",
"user",
"files",
"auth",
"statistics",
"platform-services",
"documents",
],
});
currentUser = await UserApi.getInstance(platform);
});
afterAll(async () => {
await platform?.tearDown();
platform = null;
});
describe("Pagination and Sorting", () => {
it("Should paginate documents correctly", async () => {
const myDriveId = "user_" + currentUser.user.id;
await currentUser.uploadAllFilesOneByOne(myDriveId);
let page = 1;
const limit = 2;
let docs = await currentUser.browseDocuments(myDriveId, {
paginate: { page, limit },
});
expect(docs).toBeDefined();
expect(docs.children).toHaveLength(limit);
page = 2;
docs = await currentUser.browseDocuments(myDriveId, {
paginate: { page, limit },
});
expect(docs).toBeDefined();
expect(docs.children).toHaveLength(limit);
page = 3;
docs = await currentUser.browseDocuments(myDriveId, {
paginate: { page, limit },
});
expect(docs).toBeDefined();
expect(docs.children.length).toBeLessThanOrEqual(limit);
});
it("Should sort documents by name in ascending order", async () => {
const myDriveId = "user_" + currentUser.user.id;
await currentUser.uploadAllFilesOneByOne(myDriveId);
const sortBy = "name";
const sortOrder = "asc";
const docs = await currentUser.browseDocuments(myDriveId, {
sort: { by: sortBy, order: sortOrder },
});
expect(docs).toBeDefined();
const isSorted = docs.children.every((item, i, arr) => !i || arr[i - 1].name <= item.name);
expect(isSorted).toBe(true);
});
it("Should sort documents by name in descending order", async () => {
const myDriveId = "user_" + currentUser.user.id;
await currentUser.uploadAllFilesOneByOne(myDriveId);
const sortBy = "name";
const sortOrder = "desc";
const docs = await currentUser.browseDocuments(myDriveId, {
sort: { by: sortBy, order: sortOrder },
});
expect(docs).toBeDefined();
const isSorted = docs.children.every((item, i, arr) => !i || arr[i - 1].name >= item.name);
expect(isSorted).toBe(true);
});
it("Should sort documents by date in ascending order", async () => {
const myDriveId = "user_" + currentUser.user.id;
await currentUser.uploadAllFilesOneByOne(myDriveId);
const sortBy = "date";
const sortOrder = "asc";
const docs = await currentUser.browseDocuments(myDriveId, {
sort: { by: sortBy, order: sortOrder },
});
expect(docs).toBeDefined();
const isSorted = docs.children.every(
(item, i, arr) => !i || new Date(arr[i - 1].added) <= new Date(item.added),
);
expect(isSorted).toBe(true);
});
it("Should sort documents by date in descending order", async () => {
const myDriveId = "user_" + currentUser.user.id;
await currentUser.uploadAllFilesOneByOne(myDriveId);
const sortBy = "date";
const sortOrder = "desc";
const docs = await currentUser.browseDocuments(myDriveId, {
sort: { by: sortBy, order: sortOrder },
});
expect(docs).toBeDefined();
const isSorted = docs.children.every(
(item, i, arr) => !i || new Date(arr[i - 1].added) >= new Date(item.added),
);
expect(isSorted).toBe(true);
});
it("Should sort documents by size in ascending order", async () => {
const myDriveId = "user_" + currentUser.user.id;
await currentUser.uploadAllFilesOneByOne(myDriveId);
const sortBy = "size";
const sortOrder = "asc";
const docs = await currentUser.browseDocuments(myDriveId, {
sort: { by: sortBy, order: sortOrder },
});
expect(docs).toBeDefined();
const isSorted = docs.children.every((item, i, arr) => !i || arr[i - 1].size <= item.size);
expect(isSorted).toBe(true);
});
it("Should sort documents by size in descending order", async () => {
const myDriveId = "user_" + currentUser.user.id;
await currentUser.uploadAllFilesOneByOne(myDriveId);
const sortBy = "size";
const sortOrder = "desc";
const docs = await currentUser.browseDocuments(myDriveId, {
sort: { by: sortBy, order: sortOrder },
});
expect(docs).toBeDefined();
const isSorted = docs.children.every((item, i, arr) => !i || arr[i - 1].size >= item.size);
expect(isSorted).toBe(true);
});
});
});
@@ -8,6 +8,20 @@ import {
comparisonType comparisonType
} from "../../../../../../../../../src/core/platform/services/database/services/orm/repository/repository"; } from "../../../../../../../../../src/core/platform/services/database/services/orm/repository/repository";
type SortDirection = "asc" | "desc";
type SortOption = {
[field: string]: SortDirection;
};
interface FindOptions {
sort?: SortOption;
pagination?: {
limitStr: string;
page_token: string;
};
}
describe('The PostgresQueryBuilder', () => { describe('The PostgresQueryBuilder', () => {
const subj: PostgresQueryBuilder = new PostgresQueryBuilder(""); const subj: PostgresQueryBuilder = new PostgresQueryBuilder("");
@@ -103,6 +117,43 @@ describe('The PostgresQueryBuilder', () => {
expect(query[1]).toEqual([entity.company_id, entity.id]); expect(query[1]).toEqual([entity.company_id, entity.id]);
}); });
test('buildSelect query with sort option', async () => {
//given
const options = { sort: { added: "asc" as SortDirection, id: "desc" as SortDirection } };
//when
const query = subj.buildSelect(TestDbEntity, null, options);
//then
expect(normalizeWhitespace(query[0] as string)).toBe(`SELECT * FROM "test_table" ORDER BY added ASC, id DESC LIMIT 100 OFFSET 0`);
expect(query[1]).toEqual([]);
});
test('buildSelect query with pagination', async () => {
//given
const options = { pagination: { limitStr: "50", page_token: "2" } };
//when
const query = subj.buildSelect(TestDbEntity, null, options);
//then
expect(normalizeWhitespace(query[0] as string)).toBe(`SELECT * FROM "test_table" ORDER BY id DESC LIMIT 50 OFFSET 100`);
expect(query[1]).toEqual([]);
});
test('buildSelect query with filter, sort, and pagination', async () => {
//given
const filter = { id: randomUUID(), company_id: randomUUID() };
const options = { sort: { added: "asc" as SortDirection, id: "desc" as SortDirection }, pagination: { limitStr: "10", page_token: "3" } };
//when
const query = subj.buildSelect(TestDbEntity, filter as { [key: string]: any }, options);
//then
expect(normalizeWhitespace(query[0] as string)).toBe(`SELECT * FROM "test_table" WHERE id = $1 AND company_id = $2 ORDER BY added ASC, id DESC LIMIT 10 OFFSET 30`);
expect(query[1]).toEqual([filter.id, filter.company_id]);
});
test('buildInsert', async () => { test('buildInsert', async () => {
//given //given
const entity = newTestDbEntity(); const entity = newTestDbEntity();
@@ -0,0 +1,3 @@
<svg width="13" height="10" viewBox="0 0 13 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.16 8.385C5.16 8.21393 5.22796 8.04988 5.34892 7.92892C5.46988 7.80795 5.63393 7.74 5.805 7.74H7.095C7.26606 7.74 7.43012 7.80795 7.55108 7.92892C7.67205 8.04988 7.74 8.21393 7.74 8.385C7.74 8.55606 7.67205 8.72012 7.55108 8.84108C7.43012 8.96204 7.26606 9.03 7.095 9.03H5.805C5.63393 9.03 5.46988 8.96204 5.34892 8.84108C5.22796 8.72012 5.16 8.55606 5.16 8.385ZM2.58 4.515C2.58 4.34394 2.64796 4.17988 2.76892 4.05892C2.88988 3.93795 3.05394 3.87 3.225 3.87H9.675C9.84606 3.87 10.0101 3.93795 10.1311 4.05892C10.252 4.17988 10.32 4.34394 10.32 4.515C10.32 4.68606 10.252 4.85012 10.1311 4.97108C10.0101 5.09205 9.84606 5.16 9.675 5.16H3.225C3.05394 5.16 2.88988 5.09205 2.76892 4.97108C2.64796 4.85012 2.58 4.68606 2.58 4.515ZM0 0.645C0 0.473935 0.0679553 0.309877 0.188916 0.188916C0.309877 0.0679553 0.473935 0 0.645 0H12.255C12.4261 0 12.5901 0.0679553 12.7111 0.188916C12.832 0.309877 12.9 0.473935 12.9 0.645C12.9 0.816065 12.832 0.980123 12.7111 1.10108C12.5901 1.22204 12.4261 1.29 12.255 1.29H0.645C0.473935 1.29 0.309877 1.22204 0.188916 1.10108C0.0679553 0.980123 0 0.816065 0 0.645Z" fill="#6A6A6A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -22,6 +22,7 @@ import {
StatusCheckIcon, StatusCheckIcon,
UsersIcon, UsersIcon,
CheckOutlineIcon, CheckOutlineIcon,
SortIcon,
} from '@atoms/icons-agnostic/index'; } from '@atoms/icons-agnostic/index';
export default { export default {
@@ -69,6 +70,7 @@ const Template: ComponentStory<any> = () => {
<Icon icon={<StatusCheckDoubleIcon />} title="StatusCheckDouble" /> <Icon icon={<StatusCheckDoubleIcon />} title="StatusCheckDouble" />
<Icon icon={<StatusCheckIcon />} title="StatusCheck" /> <Icon icon={<StatusCheckIcon />} title="StatusCheck" />
<Icon icon={<UsersIcon />} title="Users" /> <Icon icon={<UsersIcon />} title="Users" />
<Icon icon={<SortIcon />} title="Sort" />
</div> </div>
</> </>
); );
@@ -20,6 +20,7 @@ import { ReactComponent as X } from './assets/x.svg';
import { ReactComponent as StatusCheckDouble } from './assets/status-check-double.svg'; import { ReactComponent as StatusCheckDouble } from './assets/status-check-double.svg';
import { ReactComponent as StatusCheck } from './assets/status-check.svg'; import { ReactComponent as StatusCheck } from './assets/status-check.svg';
import { ReactComponent as Users } from './assets/users.svg'; import { ReactComponent as Users } from './assets/users.svg';
import { ReactComponent as Sort } from './assets/sort.svg';
export const CopyIcon = (props: ComponentProps<'svg'>) => <CopySvg {...props} />; export const CopyIcon = (props: ComponentProps<'svg'>) => <CopySvg {...props} />;
export const DeleteIcon = (props: ComponentProps<'svg'>) => <DeleteSvg {...props} />; export const DeleteIcon = (props: ComponentProps<'svg'>) => <DeleteSvg {...props} />;
@@ -43,3 +44,4 @@ export const StatusCheckDoubleIcon = (props: ComponentProps<'svg'>) => (
); );
export const StatusCheckIcon = (props: ComponentProps<'svg'>) => <StatusCheck {...props} />; export const StatusCheckIcon = (props: ComponentProps<'svg'>) => <StatusCheck {...props} />;
export const UsersIcon = (props: ComponentProps<'svg'>) => <Users {...props} />; export const UsersIcon = (props: ComponentProps<'svg'>) => <Users {...props} />;
export const SortIcon = (props: ComponentProps<'svg'>) => <Sort {...props} />;
@@ -26,6 +26,7 @@ export default class Menu extends React.Component {
super(props); super(props);
this.state = { this.state = {
isMenuOpen: false, isMenuOpen: false,
by: ''
}; };
this.open = false; // Added initialization for open state this.open = false; // Added initialization for open state
this.container = React.createRef(); // Ref for container div this.container = React.createRef(); // Ref for container div
@@ -94,6 +95,10 @@ export default class Menu extends React.Component {
return true; return true;
} }
if (this.props.sortData !== nextProps.sortData) {
return true;
}
return false; return false;
} }
@@ -118,7 +123,10 @@ export default class Menu extends React.Component {
}} }}
className={this.props.className} className={this.props.className}
> >
<>
{this.props.children} {this.props.children}
</>
</div> </div>
); );
} }
@@ -1,5 +1,5 @@
import Api from '../../global/framework/api-service'; import Api from '../../global/framework/api-service';
import { BrowseFilter, DriveItem, DriveItemDetails, DriveItemVersion } from '../types'; import { BrowseFilter, BrowsePaginate, BrowseQuery, BrowseSort, DriveItem, DriveItemDetails, DriveItemVersion } from '../types';
import Workspace from '@deprecated/workspaces/workspaces'; import Workspace from '@deprecated/workspaces/workspaces';
import Logger from 'features/global/framework/logger-service'; import Logger from 'features/global/framework/logger-service';
import { JWTDataType } from 'app/features/auth/jwt-storage-service'; import { JWTDataType } from 'app/features/auth/jwt-storage-service';
@@ -70,10 +70,14 @@ export class DriveApiClient {
); );
} }
static async browse(companyId: string, id: string | 'trash' | '', filter: BrowseFilter) { static async browse(companyId: string, id: string | 'trash' | '', filter: BrowseFilter, sort: BrowseSort, paginate: BrowsePaginate) {
return await Api.post<BrowseFilter, DriveItemDetails>( return await Api.post<BrowseQuery, DriveItemDetails>(
`/internal/services/documents/v1/companies/${companyId}/browse/${id}${appendTdriveToken()}`, `/internal/services/documents/v1/companies/${companyId}/browse/${id}${appendTdriveToken()}`,
filter, {
filter,
sort,
paginate
},
); );
} }
@@ -134,11 +138,11 @@ export class DriveApiClient {
return Api.route(`/internal/services/documents/v1/companies/${companyId}/item/${id}/download`); return Api.route(`/internal/services/documents/v1/companies/${companyId}/item/${id}/download`);
} }
static async getDownloadZipUrl(companyId: string, ids: string[]) { static async getDownloadZipUrl(companyId: string, ids: string[], isDirectory?: boolean) {
// const { token } = await DriveApiClient.getDownloadToken(companyId, ids); // const { token } = await DriveApiClient.getDownloadToken(companyId, ids);
return Api.route( return Api.route(
`/internal/services/documents/v1/companies/${companyId}/item/download/zip` + `/internal/services/documents/v1/companies/${companyId}/item/download/zip` +
`?items=${ids.join(',')}` `?items=${ids.join(',')}&is_directory=${isDirectory}`,
); );
} }
@@ -1,9 +1,9 @@
import { ToasterService } from '@features/global/services/toaster-service'; import { ToasterService } from '@features/global/services/toaster-service';
import useRouterCompany from '@features/router/hooks/use-router-company'; import useRouterCompany from '@features/router/hooks/use-router-company';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useRecoilValue, useRecoilCallback } from 'recoil'; import { useRecoilValue, useRecoilCallback, useRecoilState } from 'recoil';
import { DriveApiClient } from '../api-client/api-client'; import { DriveApiClient } from '../api-client/api-client';
import { DriveItemAtom, DriveItemChildrenAtom } from '../state/store'; import { DriveItemAtom, DriveItemChildrenAtom, DriveItemPagination, DriveItemSort } from '../state/store';
import { BrowseFilter, DriveItem, DriveItemVersion } from '../types'; import { BrowseFilter, DriveItem, DriveItemVersion } from '../types';
import { SharedWithMeFilterState } from '../state/shared-with-me-filter'; import { SharedWithMeFilterState } from '../state/shared-with-me-filter';
import Languages from 'features/global/services/languages-service'; import Languages from 'features/global/services/languages-service';
@@ -16,18 +16,26 @@ import { useUserQuota } from 'features/users/hooks/use-user-quota';
export const useDriveActions = () => { export const useDriveActions = () => {
const companyId = useRouterCompany(); const companyId = useRouterCompany();
const sharedFilter = useRecoilValue(SharedWithMeFilterState); const sharedFilter = useRecoilValue(SharedWithMeFilterState);
const sortItem = useRecoilValue(DriveItemSort);
const [paginateItem, _] = useRecoilState(DriveItemPagination);
const { getQuota } = useUserQuota(); const { getQuota } = useUserQuota();
const refresh = useRecoilCallback( const refresh = useRecoilCallback(
({ set, snapshot }) => ({ set, snapshot }) =>
async (parentId: string) => { async (parentId: string, resetPagination?: boolean) => {
if (parentId) { if (parentId) {
const filter: BrowseFilter = { const filter: BrowseFilter = {
company_id: companyId, company_id: companyId,
mime_type: sharedFilter.mimeType.value, mime_type: sharedFilter.mimeType.value,
}; };
let pagination = await snapshot.getPromise(DriveItemPagination);
if (resetPagination) {
pagination = { page: 0, limit: pagination.limit };
set(DriveItemPagination, pagination);
}
try { try {
const details = await DriveApiClient.browse(companyId, parentId, filter); const details = await DriveApiClient.browse(companyId, parentId, filter, sortItem, pagination);
set(DriveItemChildrenAtom(parentId), details.children); set(DriveItemChildrenAtom(parentId), details.children);
set(DriveItemAtom(parentId), details); set(DriveItemAtom(parentId), details);
for (const child of details.children) { for (const child of details.children) {
@@ -40,10 +48,12 @@ export const useDriveActions = () => {
return details; return details;
} catch (e) { } catch (e) {
ToasterService.error(Languages.t('hooks.use-drive-actions.unable_load_file')); ToasterService.error(Languages.t('hooks.use-drive-actions.unable_load_file'));
} finally {
set(DriveItemPagination, { page: pagination.limit, limit: pagination.limit });
} }
} }
}, },
[companyId], [companyId, sortItem],
); );
const create = useCallback( const create = useCallback(
@@ -54,7 +64,7 @@ export const useDriveActions = () => {
try { try {
const driveFile = await DriveApiClient.create(companyId, { item, version }); const driveFile = await DriveApiClient.create(companyId, { item, version });
await refresh(driveFile.parent_id); await refresh(driveFile.parent_id, true);
await getQuota(); await getQuota();
return driveFile; return driveFile;
@@ -89,9 +99,9 @@ export const useDriveActions = () => {
); );
const downloadZip = useCallback( const downloadZip = useCallback(
async (ids: string[]) => { async (ids: string[], isDirectory?: boolean) => {
try { try {
const url = await DriveApiClient.getDownloadZipUrl(companyId, ids); const url = await DriveApiClient.getDownloadZipUrl(companyId, ids, isDirectory);
(window as any).open(url, '_blank').focus(); (window as any).open(url, '_blank').focus();
} catch (e) { } catch (e) {
ToasterService.error(Languages.t('hooks.use-drive-actions.unable_download_file')); ToasterService.error(Languages.t('hooks.use-drive-actions.unable_download_file'));
@@ -129,9 +139,9 @@ export const useDriveActions = () => {
async (update: Partial<DriveItem>, id: string, parentId: string) => { async (update: Partial<DriveItem>, id: string, parentId: string) => {
try { try {
await DriveApiClient.update(companyId, id, update); await DriveApiClient.update(companyId, id, update);
await refresh(id || ''); await refresh(id || '', true);
await refresh(parentId || ''); await refresh(parentId || '', true);
if (update?.parent_id !== parentId) await refresh(update?.parent_id || ''); if (update?.parent_id !== parentId) await refresh(update?.parent_id || '', true);
} catch (e) { } catch (e) {
ToasterService.error(Languages.t('hooks.use-drive-actions.unable_update_file')); ToasterService.error(Languages.t('hooks.use-drive-actions.unable_update_file'));
} }
@@ -156,5 +166,25 @@ export const useDriveActions = () => {
[refresh], [refresh],
); );
return { create, refresh, download, downloadZip, remove, restore, update, updateLevel }; const nextPage = useRecoilCallback(
({ snapshot }) =>
async (parentId: string) => {
const filter: BrowseFilter = {
company_id: companyId,
mime_type: sharedFilter.mimeType.value,
};
const pagination = await snapshot.getPromise(DriveItemPagination);
const details = await DriveApiClient.browse(
companyId,
parentId,
filter,
sortItem,
pagination
);
return details;
},
[paginateItem, refresh],
);
return { create, refresh, download, downloadZip, remove, restore, update, updateLevel, nextPage };
}; };
@@ -1,9 +1,9 @@
import { ToasterService } from '@features/global/services/toaster-service'; import { ToasterService } from '@features/global/services/toaster-service';
import { LoadingStateInitTrue } from '@features/global/state/atoms/Loading'; import { LoadingStateInitTrue } from '@features/global/state/atoms/Loading';
import useRouterCompany from '@features/router/hooks/use-router-company'; import useRouterCompany from '@features/router/hooks/use-router-company';
import { useCallback } from 'react'; import { useCallback, useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil';
import { DriveItemAtom, DriveItemChildrenAtom } from '../state/store'; import { DriveItemAtom, DriveItemChildrenAtom, DriveItemPagination } from '../state/store';
import { DriveItem } from '../types'; import { DriveItem } from '../types';
import { useDriveActions } from './use-drive-actions'; import { useDriveActions } from './use-drive-actions';
import { useDriveUpload } from './use-drive-upload'; import { useDriveUpload } from './use-drive-upload';
@@ -17,8 +17,10 @@ import short from 'short-uuid';
export const useDriveItem = (id: string) => { export const useDriveItem = (id: string) => {
const companyId = useRouterCompany(); const companyId = useRouterCompany();
const item = useRecoilValue(DriveItemAtom(id)); const item = useRecoilValue(DriveItemAtom(id));
const children = useRecoilValue(DriveItemChildrenAtom(id)); // const children = useRecoilValue(DriveItemChildrenAtom(id));
const [children, setChildren] = useRecoilState(DriveItemChildrenAtom(id));
const [loading, setLoading] = useRecoilState(LoadingStateInitTrue('useDriveItem-' + id)); const [loading, setLoading] = useRecoilState(LoadingStateInitTrue('useDriveItem-' + id));
const [_, setPaginateItem] = useRecoilState(DriveItemPagination);
const { const {
refresh: refreshItem, refresh: refreshItem,
create, create,
@@ -26,19 +28,20 @@ export const useDriveItem = (id: string) => {
updateLevel: _updateLevel, updateLevel: _updateLevel,
remove: _remove, remove: _remove,
restore: _restore, restore: _restore,
nextPage,
} = useDriveActions(); } = useDriveActions();
const { uploadVersion: _uploadVersion } = useDriveUpload(); const { uploadVersion: _uploadVersion } = useDriveUpload();
const refresh = useCallback( const refresh = useCallback(
async (parentId: string) => { async (parentId: string, resetPagination?: boolean) => {
setLoading(true); setLoading(true);
try { try {
await refreshItem(parentId); setPaginateItem(prev => ({ ...prev, page: 0 }));
await refreshItem(parentId, resetPagination);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, },
[setLoading, refreshItem], [id, setLoading, refreshItem],
); );
const remove = useCallback(async () => { const remove = useCallback(async () => {
@@ -100,6 +103,51 @@ export const useDriveItem = (id: string) => {
[companyId, id, setLoading, refresh, item?.item?.parent_id], [companyId, id, setLoading, refresh, item?.item?.parent_id],
); );
const loadNextPage = useRecoilCallback(
({ set, snapshot }) =>
async (id: string) => {
// get current pagination state
const pagination = await snapshot.getPromise(DriveItemPagination);
// if end is true, do not load more
if (pagination.lastPage === true) return;
setLoading(true);
try {
const details = await nextPage(id);
if (details.children.length === 0) {
set(DriveItemPagination, prev => ({
...prev,
lastPage: true,
}));
}
// set children and remove duplicates
setChildren(prev => [
...prev,
...details.children.filter(
(item, index, self) =>
index === self.findIndex(t => t.id === item.id),
),
]);
} catch (e) {
// set pagination end to true
set(DriveItemPagination, prev => ({
...prev,
lastPage: true,
}));
console.log('error loading next page: ', e);
ToasterService.error('Unable to load more items.');
} finally {
set(DriveItemPagination, prev => ({
...prev,
page: (prev.page + prev.limit),
}));
}
setLoading(false);
},
[id, nextPage],
);
const inTrash = const inTrash =
id.includes('trash') || id.includes('trash') ||
item?.path?.some(i => i?.parent_id?.includes('trash')) || item?.path?.some(i => i?.parent_id?.includes('trash')) ||
@@ -123,6 +171,7 @@ export const useDriveItem = (id: string) => {
updateLevel, updateLevel,
remove, remove,
refresh, refresh,
loadNextPage,
}; };
}; };
@@ -29,11 +29,17 @@ export const useDrivePreviewModal = () => {
} }
}; };
const openWithId: (id: string) => void = (id: string) => {
DriveApiClient.get(company, id).then((item) => {
open(item?.item);
});
}
const close = () => { const close = () => {
setStatus({ item: null, loading: true }); setStatus({ item: null, loading: true });
} }
return { open, close, isOpen: !!status.item }; return { open, close, isOpen: !!status.item, openWithId };
}; };
export const useDrivePreview = () => { export const useDrivePreview = () => {
@@ -50,7 +50,7 @@ export const useDriveUpload = () => {
// Create all directories // Create all directories
logger.debug("Start creating directories ..."); logger.debug("Start creating directories ...");
const filesPerParentId = await FileUploadService.createDirectories(tree.tree, context); const filesPerParentId = await FileUploadService.createDirectories(tree.tree, context);
await refresh(context.parentId); await refresh(context.parentId, true);
logger.debug("All directories created"); logger.debug("All directories created");
// Upload files into directories // Upload files into directories
@@ -90,7 +90,7 @@ export const useDriveUpload = () => {
}, },
}); });
} }
await refresh(context.parentId); await refresh(context.parentId, true);
}; };
const uploadFromUrl = ( const uploadFromUrl = (
@@ -1,5 +1,5 @@
import { atomFamily, atom } from 'recoil'; import { atomFamily, atom } from 'recoil';
import { DriveItem, DriveItemDetails } from '../types'; import { BrowsePaginate, BrowseSort, DriveItem, DriveItemDetails } from '../types';
export const DriveItemChildrenAtom = atomFamily<DriveItem[], string>({ export const DriveItemChildrenAtom = atomFamily<DriveItem[], string>({
key: 'DriveItemChildrenAtom', key: 'DriveItemChildrenAtom',
@@ -15,3 +15,20 @@ export const DriveItemSelectedList = atom<{[key: string]: boolean }>({
key: 'DriveItemSelectedList', key: 'DriveItemSelectedList',
default: {} default: {}
}); });
export const DriveItemSort = atom<BrowseSort>({
key: 'DriveItemSort',
default: {
by: 'date',
order: 'desc',
},
});
export const DriveItemPagination = atom<BrowsePaginate>({
key: 'DriveItemPagination',
default: {
page: 0,
limit: 15,
lastPage: false,
},
});
@@ -1,8 +1,25 @@
export type BrowseQuery = {
filter: BrowseFilter;
sort: BrowseSort;
paginate: BrowsePaginate;
}
export type BrowseFilter = { export type BrowseFilter = {
company_id: string; company_id: string;
mime_type?: string; mime_type?: string;
} }
export type BrowseSort = {
by: string;
order: string;
}
export type BrowsePaginate = {
page: number;
limit: number;
lastPage?: boolean;
};
export type DriveItemDetails = { export type DriveItemDetails = {
item: DriveItem; item: DriveItem;
versions: DriveItemVersion[]; versions: DriveItemVersion[];
@@ -7,21 +7,23 @@ import UploadZone from '@components/uploads/upload-zone';
import { setTdriveTabToken } from '@features/drive/api-client/api-client'; import { setTdriveTabToken } from '@features/drive/api-client/api-client';
import { useDriveItem } from '@features/drive/hooks/use-drive-item'; import { useDriveItem } from '@features/drive/hooks/use-drive-item';
import { useDriveUpload } from '@features/drive/hooks/use-drive-upload'; import { useDriveUpload } from '@features/drive/hooks/use-drive-upload';
import { DriveItemSelectedList } from '@features/drive/state/store'; import { DriveItemSelectedList, DriveItemSort } from '@features/drive/state/store';
import { formatBytes } from '@features/drive/utils'; import { formatBytes } from '@features/drive/utils';
import useRouterCompany from '@features/router/hooks/use-router-company'; import useRouterCompany from '@features/router/hooks/use-router-company';
import _ from 'lodash'; import _, { set } from 'lodash';
import { memo, Suspense, useCallback, useEffect, useRef, useState } from 'react'; import { memo, Suspense, useCallback, useEffect, useRef, useState } from 'react';
import { atomFamily, useRecoilState, useSetRecoilState } from 'recoil'; import { atomFamily, useRecoilState, useSetRecoilState, useRecoilValue } from 'recoil';
import { DrivePreview } from '../../viewer/drive-preview'; import { DrivePreview } from '../../viewer/drive-preview';
import { import {
useOnBuildContextMenu, useOnBuildContextMenu,
useOnBuildFileTypeContextMenu, useOnBuildFileTypeContextMenu,
useOnBuildPeopleContextMenu, useOnBuildPeopleContextMenu,
useOnBuildDateContextMenu, useOnBuildDateContextMenu,
useOnBuildSortContextMenu,
} from './context-menu'; } from './context-menu';
import {DocumentRow, DocumentRowOverlay} from './documents/document-row'; import {DocumentRow, DocumentRowOverlay} from './documents/document-row';
import { FolderRow } from './documents/folder-row'; import { FolderRow } from './documents/folder-row';
import { FolderRowSkeleton } from './documents/folder-row-skeleton';
import HeaderPath from './header-path'; import HeaderPath from './header-path';
import { ConfirmDeleteModal } from './modals/confirm-delete'; import { ConfirmDeleteModal } from './modals/confirm-delete';
import { ConfirmTrashModal } from './modals/confirm-trash'; import { ConfirmTrashModal } from './modals/confirm-trash';
@@ -46,6 +48,8 @@ import { ConfirmModalAtom } from './modals/confirm-move/index';
import { useCurrentUser } from 'app/features/users/hooks/use-current-user'; import { useCurrentUser } from 'app/features/users/hooks/use-current-user';
import { ConfirmModal } from './modals/confirm-move'; import { ConfirmModal } from './modals/confirm-move';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { SortIcon } from 'app/atoms/icons-agnostic';
import { useDrivePreview, useDrivePreviewLoading } from 'app/features/drive/hooks/use-drive-preview';
export const DriveCurrentFolderAtom = atomFamily< export const DriveCurrentFolderAtom = atomFamily<
string, string,
@@ -70,19 +74,32 @@ export default memo(
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const companyId = useRouterCompany(); const companyId = useRouterCompany();
const history = useHistory(); const history = useHistory();
const role = user ? (user?.companies || []).find(company => company?.company.id === companyId)?.role : "member"; const role = user
? (user?.companies || []).find(company => company?.company.id === companyId)?.role
: 'member';
setTdriveTabToken(tdriveTabContextToken || null); setTdriveTabToken(tdriveTabContextToken || null);
const [filter, __] = useRecoilState(SharedWithMeFilterState); const [filter, __] = useRecoilState(SharedWithMeFilterState);
const { viewId, dirId } = useRouteState(); const { viewId, dirId, itemId } = useRouteState();
const { status } = useDrivePreview();
const { openWithId, close } = useDrivePreview();
const [sortLabel] = useRecoilState(DriveItemSort)
const { loading: isModalLoading } = useDrivePreviewLoading();
const [parentId, _setParentId] = useRecoilState( const [parentId, _setParentId] = useRecoilState(
DriveCurrentFolderAtom({ context: context, initialFolderId: dirId || viewId || initialParentId || 'user_'+user?.id }), DriveCurrentFolderAtom({
context: context,
initialFolderId: dirId || viewId || initialParentId || 'user_' + user?.id,
}),
); );
// set the initial view to the user's home directory // set the initial view to the user's home directory
useEffect(() => { useEffect(() => {
!dirId && !viewId && history.push(RouterServices.generateRouteFromState({viewId: parentId})); !dirId &&
!viewId &&
history.push(RouterServices.generateRouteFromState({ viewId: parentId }));
}, [viewId, dirId]); }, [viewId, dirId]);
const [loadingParentChange, setLoadingParentChange] = useState(false); const [loadingParentChange, setLoadingParentChange] = useState(false);
const { const {
sharedWithMe, sharedWithMe,
@@ -94,6 +111,7 @@ export default memo(
children, children,
loading: loadingParent, loading: loadingParent,
path, path,
loadNextPage,
} = useDriveItem(parentId); } = useDriveItem(parentId);
const { uploadTree } = useDriveUpload(); const { uploadTree } = useDriveUpload();
@@ -131,10 +149,6 @@ export default memo(
if (item?.id) setUploadModalState({ open: true, parent_id: item.id }); if (item?.id) setUploadModalState({ open: true, parent_id: item.id });
}, [item?.id, setUploadModalState]); }, [item?.id, setUploadModalState]);
const selectedCount = Object.values(checked).filter(v => v).length;
const folders = children
.filter(i => i.is_directory)
.sort((a, b) => a.name.localeCompare(b.name));
const documents = ( const documents = (
item?.is_directory === false item?.is_directory === false
? //We use this hack for public shared single file ? //We use this hack for public shared single file
@@ -142,27 +156,28 @@ export default memo(
? [item] ? [item]
: [] : []
: children : children
) ).filter(i => !i.is_directory);
.filter(i => !i.is_directory)
.sort((a, b) => a.name.localeCompare(b.name)); const selectedCount = Object.values(checked).filter(v => v).length;
const onBuildContextMenu = useOnBuildContextMenu(children, initialParentId); const onBuildContextMenu = useOnBuildContextMenu(children, initialParentId);
const onBuildSortContextMenu = useOnBuildSortContextMenu();
const handleDragOver = (event: { preventDefault: () => void; }) => { const handleDragOver = (event: { preventDefault: () => void }) => {
event.preventDefault(); event.preventDefault();
} };
const handleDrop = async (event: {dataTransfer: any; preventDefault: () => void;}) => { const handleDrop = async (event: { dataTransfer: any; preventDefault: () => void }) => {
event.preventDefault(); event.preventDefault();
const dataTransfer = event.dataTransfer; const dataTransfer = event.dataTransfer;
if (dataTransfer) { if (dataTransfer) {
const tree = await getFilesTree(dataTransfer); const tree = await getFilesTree(dataTransfer);
setCreationModalState({ parent_id: '', open: false }); setCreationModalState({ parent_id: '', open: false });
await uploadTree(tree, { await uploadTree(tree, {
companyId, companyId,
parentId, parentId,
}); });
} }
} };
const buildFileTypeContextMenu = useOnBuildFileTypeContextMenu(); const buildFileTypeContextMenu = useOnBuildFileTypeContextMenu();
const buildPeopleContextMen = useOnBuildPeopleContextMenu(); const buildPeopleContextMen = useOnBuildPeopleContextMenu();
@@ -170,24 +185,24 @@ export default memo(
const setConfirmModalState = useSetRecoilState(ConfirmModalAtom); const setConfirmModalState = useSetRecoilState(ConfirmModalAtom);
const [activeIndex, setActiveIndex] = useState(null); const [activeIndex, setActiveIndex] = useState(null);
const [activeChild, setActiveChild] = useState(null); const [activeChild, setActiveChild] = useState(null);
const {update} = useDriveActions(); const { update } = useDriveActions();
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { activationConstraint: {
distance: 8, distance: 8,
}, },
}) }),
); );
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
function handleDragStart(event:any) { function handleDragStart(event: any) {
setActiveIndex(event.active.id); setActiveIndex(event.active.id);
setActiveChild(event.active.data.current.child.props.item); setActiveChild(event.active.data.current.child.props.item);
} }
function handleDragEnd(event:any) { function handleDragEnd(event: any) {
setActiveIndex(null); setActiveIndex(null);
setActiveChild(null); setActiveChild(null);
if (event.over){ if (event.over) {
setConfirmModalState({ setConfirmModalState({
open: true, open: true,
parent_id: inTrash ? 'root' : event.over.data.current.child.props.item.id, parent_id: inTrash ? 'root' : event.over.data.current.child.props.item.id,
@@ -204,35 +219,49 @@ export default memo(
event.active.data.current.child.props.item.parent_id, event.active.data.current.child.props.item.parent_id,
); );
}, },
}) });
} }
} }
function draggableMarkup(index: number, child: any) { function draggableMarkup(index: number, child: any) {
const commonProps = { const commonProps = {
key: index, key: index,
className: className:
(index === 0 ? 'rounded-t-md ' : '') + (index === 0 ? 'rounded-t-md ' : '') +
(index === documents.length - 1 ? 'rounded-b-md ' : ''), (index === documents.length - 1 ? 'rounded-b-md ' : ''),
item: child, item: child,
checked: checked[child.id] || false, checked: checked[child.id] || false,
onCheck: (v: boolean) => onCheck: (v: boolean) => setChecked(_.pickBy({ ...checked, [child.id]: v }, _.identity)),
setChecked(_.pickBy({ ...checked, [child.id]: v }, _.identity)),
onBuildContextMenu: () => onBuildContextMenu(details, child), onBuildContextMenu: () => onBuildContextMenu(details, child),
inPublicSharing, inPublicSharing,
}; };
return ( return isMobile ? (
isMobile ? ( <DocumentRow {...commonProps} />
<DocumentRow {...commonProps} /> ) : (
) : ( <Draggable id={index}>
<Draggable id={index} key={index}> <DocumentRow {...commonProps} />
<DocumentRow {...commonProps} /> </Draggable>
</Draggable>
)
); );
} }
// Infinite scroll
const scrollViwer = useRef<HTMLDivElement>(null);
const handleScroll = async () => {
const scrollTop = scrollViwer.current?.scrollTop || 0;
const scrollHeight = scrollViwer.current?.scrollHeight || 0;
const clientHeight = scrollViwer.current?.clientHeight || 0;
if (scrollTop > 0 && scrollTop + clientHeight >= scrollHeight) {
await loadNextPage(parentId);
}
};
useEffect(() => {
if(!loading) scrollViwer.current?.addEventListener('scroll', handleScroll, { passive: true });
return () => {
scrollViwer.current?.removeEventListener('scroll', handleScroll);
};
}, [parentId, loading]);
return ( return (
<> <>
@@ -363,6 +392,19 @@ export default memo(
{formatBytes(item?.size || 0)} {Languages.t('scenes.app.drive.used')} {formatBytes(item?.size || 0)} {Languages.t('scenes.app.drive.used')}
</BaseSmall> </BaseSmall>
)} )}
<Menu menu={() => onBuildSortContextMenu()} sortData={sortLabel}>
{' '}
<Button theme="outline" className="ml-4 flex flex-row items-center">
<SortIcon
className={`h-4 w-4 mr-2 -ml-1 ${
sortLabel.order === 'asc' ? 'transform rotate-180' : ''
}`}
/>
<span>By {sortLabel.by}</span>
<ChevronDownIcon className="h-4 w-4 ml-2 -mr-1" />
</Button>
</Menu>
{viewId !== 'shared_with_me' && ( {viewId !== 'shared_with_me' && (
<Menu menu={() => onBuildContextMenu(details)}> <Menu menu={() => onBuildContextMenu(details)}>
{' '} {' '}
@@ -380,43 +422,7 @@ export default memo(
</div> </div>
<DndContext sensors={sensors} onDragEnd={handleDragEnd} onDragStart={handleDragStart}> <DndContext sensors={sensors} onDragEnd={handleDragEnd} onDragStart={handleDragStart}>
<div className="grow overflow-auto"> <div className="grow overflow-auto" ref={scrollViwer}>
{folders.length > 0 && (
<>
<Title className="mb-2 block">
{Languages.t('scenes.app.drive.folders')}
</Title>
{folders.map((child, index) => (
<Droppable id={index} key={index}>
<FolderRow
key={index}
className={
(index === 0 ? 'rounded-t-md ' : '') +
(index === folders.length - 1 ? 'rounded-b-md ' : '')
}
item={child}
onClick={() => {
const route = RouterServices.generateRouteFromState({
dirId: child.id,
});
history.push(route);
if (inPublicSharing) return setParentId(child.id);
}}
checked={checked[child.id] || false}
onCheck={v =>
setChecked(_.pickBy({ ...checked, [child.id]: v }, _.identity))
}
onBuildContextMenu={() => onBuildContextMenu(details, child)}
/>
</Droppable>
))}
<div className="my-6" />
</>
)}
<Title className="mb-2 block">{Languages.t('scenes.app.drive.documents')}</Title>
{documents.length === 0 && !loading && ( {documents.length === 0 && !loading && (
<div className="mt-4 text-center border-2 border-dashed rounded-md p-8"> <div className="mt-4 text-center border-2 border-dashed rounded-md p-8">
<Subtitle className="block mb-2"> <Subtitle className="block mb-2">
@@ -437,19 +443,46 @@ export default memo(
)} )}
</div> </div>
)} )}
{children.map((child, index) =>
{documents.map((child, index) => draggableMarkup(index, child))} child.is_directory ? (
<Droppable id={index} key={index}>
<FolderRow
key={index}
className={
(index === 0 ? 'rounded-t-md ' : '') +
(index === children.length - 1 ? 'rounded-b-md ' : '')
}
item={child}
onClick={() => {
const route = RouterServices.generateRouteFromState({
dirId: child.id,
});
history.push(route);
if (inPublicSharing) return setParentId(child.id);
}}
checked={checked[child.id] || false}
onCheck={v =>
setChecked(_.pickBy({ ...checked, [child.id]: v }, _.identity))
}
onBuildContextMenu={() => onBuildContextMenu(details, child)}
/>
</Droppable>
) : (
draggableMarkup(index, child)
),
)}
<DragOverlay> <DragOverlay>
{activeIndex ? ( {activeIndex ? (
<DocumentRowOverlay <DocumentRowOverlay
className={ className={
(activeIndex === 0 ? 'rounded-t-md ' : '') + (activeIndex === 0 ? 'rounded-t-md ' : '') +
(activeIndex === documents.length - 1 ? 'rounded-b-md ' : '') (activeIndex === children.length - 1 ? 'rounded-b-md ' : '')
} }
item={activeChild} item={activeChild}
></DocumentRowOverlay> ></DocumentRowOverlay>
) : null} ) : null}
</DragOverlay> </DragOverlay>
{loading && <FolderRowSkeleton />}
</div> </div>
</DndContext> </DndContext>
</div> </div>
@@ -15,7 +15,7 @@ import { DriveApiClient, getPublicLinkToken } from '@features/drive/api-client/a
import { useDriveActions } from '@features/drive/hooks/use-drive-actions'; import { useDriveActions } from '@features/drive/hooks/use-drive-actions';
import { getPublicLink } from '@features/drive/hooks/use-drive-item'; import { getPublicLink } from '@features/drive/hooks/use-drive-item';
import { useDrivePreview } from '@features/drive/hooks/use-drive-preview'; import { useDrivePreview } from '@features/drive/hooks/use-drive-preview';
import { DriveItemSelectedList } from '@features/drive/state/store'; import { DriveItemSelectedList, DriveItemSort } from '@features/drive/state/store';
import { DriveItem, DriveItemDetails } from '@features/drive/types'; import { DriveItem, DriveItemDetails } from '@features/drive/types';
import { ToasterService } from '@features/global/services/toaster-service'; import { ToasterService } from '@features/global/services/toaster-service';
import { copyToClipboard } from '@features/global/utils/CopyClipboard'; import { copyToClipboard } from '@features/global/utils/CopyClipboard';
@@ -24,7 +24,7 @@ import { getCurrentUserList } from '@features/users/hooks/use-user-list';
import useRouteState from 'app/features/router/hooks/use-route-state'; import useRouteState from 'app/features/router/hooks/use-route-state';
import RouterServices from '@features/router/services/router-service'; import RouterServices from '@features/router/services/router-service';
import useRouterCompany from '@features/router/hooks/use-router-company'; import useRouterCompany from '@features/router/hooks/use-router-company';
import _ from 'lodash'; import _, { set } from 'lodash';
import Languages from 'features/global/services/languages-service'; import Languages from 'features/global/services/languages-service';
import { hasAnyPublicLinkAccess } from '@features/files/utils/access-info-helpers'; import { hasAnyPublicLinkAccess } from '@features/files/utils/access-info-helpers';
@@ -301,9 +301,7 @@ export const useOnBuildContextMenu = (children: DriveItem[], initialParentId?: s
hide: inTrash, hide: inTrash,
onClick: () => { onClick: () => {
if (parent.children && parent.children.length > 0) { if (parent.children && parent.children.length > 0) {
const idsFromArray = getIdsFromArray(parent.children); downloadZip([parent.item!.id]);
console.log("Download zip file with docs: " + idsFromArray);
downloadZip(idsFromArray);
} else if (parent.item) { } else if (parent.item) {
console.log("Download folder itself"); console.log("Download folder itself");
download(parent.item.id); download(parent.item.id);
@@ -519,3 +517,84 @@ export const useOnBuildFileContextMenu = () => {
[download, preview], [download, preview],
); );
}; };
export const useOnBuildSortContextMenu = () => {
const [sortItem, setSortItem] = useRecoilState(DriveItemSort);
return useCallback(() => {
const menuItems = [
{
type: 'menu',
text: 'Date',
icon: sortItem.by === 'date' ? 'check' : 'sort-check',
onClick: () => {
// keep the old value for sortItem and change the by value
setSortItem(prevSortItem => {
const newSortItem = {
...prevSortItem,
by: 'date',
};
return newSortItem;
});
},
},
{
type: 'menu',
text: 'Name',
icon: sortItem.by === 'name' ? 'check' : 'sort-check',
onClick: () => {
setSortItem(prevSortItem => {
const newSortItem = {
...prevSortItem,
by: 'name',
};
return newSortItem;
});
},
},
{
type: 'menu',
text: 'Size',
icon: sortItem.by === 'size' ? 'check' : 'sort-check',
onClick: () => {
setSortItem(prevSortItem => {
const newSortItem = {
...prevSortItem,
by: 'size',
};
return newSortItem;
});
},
},
{type:"separator"},
{
type: 'menu',
text: 'Ascending',
icon: sortItem.order === 'asc' ? 'check' : 'sort-check',
onClick: () => {
setSortItem(prevSortItem => {
const newSortItem = {
...prevSortItem,
order: 'asc',
};
return newSortItem;
});
},
},
{
type: 'menu',
text: 'Descending',
icon: sortItem.order === 'desc' ? 'check' : 'sort-check',
onClick: () => {
setSortItem(prevSortItem => {
const newSortItem = {
...prevSortItem,
order: 'desc',
};
return newSortItem;
});
},
}
];
return menuItems;
}, [sortItem]);
};
@@ -27,24 +27,13 @@ export const DocumentRow = ({
}: DriveItemProps) => { }: DriveItemProps) => {
const history = useHistory(); const history = useHistory();
const [hover, setHover] = useState(false); const [hover, setHover] = useState(false);
const { open, close } = useDrivePreview(); const { open, close, isOpen } = useDrivePreview();
const company = useRouterCompany(); const company = useRouterCompany();
const { itemId } = useRouteState();
useEffect(() => {
// close the preview if the item is not set or the user navigated away
if (!itemId) {
close();
}
// open the preview if the item is set
if (itemId == item.id) {
open(item);
}
}, [itemId]);
const preview = () => { const preview = () => {
open(item);
history.push(RouterServices.generateRouteFromState({ companyId: company, itemId: item.id })); history.push(RouterServices.generateRouteFromState({ companyId: company, itemId: item.id }));
if (inPublicSharing) open(item); // history.push(RouterServices.generateRouteFromState({ companyId: company, itemId: item.id }));
}; };
return ( return (
@@ -0,0 +1,23 @@
import React from 'react';
export const FolderRowSkeleton = () => {
return (
<div className="flex flex-row items-center border border-zinc-200 dark:border-zinc-800 -mt-px px-4 py-3 cursor-pointer animate-pulse">
<div className="mr-2 -ml-1">
<div className="bg-gray-300 h-5 w-5 rounded-full"></div>
</div>
<div className="grow text-ellipsis whitespace-nowrap overflow-hidden">
<div className="bg-gray-300 h-6 w-3/4 rounded"></div>
</div>
<div className="shrink-0 ml-4">
<div className="bg-gray-300 h-5 w-5 rounded-full"></div>
</div>
<div className="shrink-0 ml-4 text-right minWidth80">
<div className="bg-gray-300 h-4 w-20 rounded"></div>
</div>
<div className="shrink-0 ml-4">
<div className="bg-gray-300 h-8 w-8 rounded-full"></div>
</div>
</div>
);
};
@@ -104,6 +104,7 @@ export const DrivePreview: React.FC<DrivePreviewProps> = ({ items }) => {
className="z-10 cursor-pointer absolute right-5 top-5 w-20 h-20 text-white hover:text-black rounded-full p-1 bg-gray-500 hover:bg-white bg-opacity-25" className="z-10 cursor-pointer absolute right-5 top-5 w-20 h-20 text-white hover:text-black rounded-full p-1 bg-gray-500 hover:bg-white bg-opacity-25"
onClick={() => { onClick={() => {
close(); close();
// small delay to allow the modal to close
history.push(RouterServices.generateRouteFromState({ companyId: company, itemId: '' })); history.push(RouterServices.generateRouteFromState({ companyId: company, itemId: '' }));
}} }}
/> />