mirror of
https://github.com/laurent22/joplin.git
synced 2024-11-30 08:26:59 +02:00
Server: Improved Items table and added item size to it
This commit is contained in:
parent
a3f8cd4850
commit
7f05420fda
2
packages/lib/package-lock.json
generated
2
packages/lib/package-lock.json
generated
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/lib",
|
||||
"version": "1.8.2",
|
||||
"version": "2.0.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
2
packages/renderer/package-lock.json
generated
2
packages/renderer/package-lock.json
generated
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/renderer",
|
||||
"version": "1.8.2",
|
||||
"version": "2.0.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
5
packages/server/package-lock.json
generated
5
packages/server/package-lock.json
generated
@ -6726,6 +6726,11 @@
|
||||
"resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz",
|
||||
"integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc="
|
||||
},
|
||||
"pretty-bytes": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
||||
"integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="
|
||||
},
|
||||
"pretty-format": {
|
||||
"version": "26.6.2",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz",
|
||||
|
@ -32,6 +32,7 @@
|
||||
"node-env-file": "^0.1.8",
|
||||
"nodemon": "^2.0.6",
|
||||
"pg": "^8.5.1",
|
||||
"pretty-bytes": "^5.6.0",
|
||||
"query-string": "^6.8.3",
|
||||
"sqlite3": "^4.1.0",
|
||||
"yargs": "^14.0.0"
|
||||
|
@ -43,4 +43,8 @@ table.table .nowrap {
|
||||
|
||||
table.table .stretch {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
table.table th .sort-button i {
|
||||
margin-left: 0.5rem;
|
||||
}
|
@ -108,7 +108,7 @@ export default class ChangeModel extends BaseModel<Change> {
|
||||
|
||||
const query = this.changesForUserQuery(userId);
|
||||
const countQuery = query.clone();
|
||||
const itemCount = (await countQuery.count('id', { as: 'total' }))[0].total;
|
||||
const itemCount = (await countQuery.countDistinct('id', { as: 'total' }))[0].total;
|
||||
|
||||
void query
|
||||
.orderBy(pagination.order[0].by, pagination.order[0].dir)
|
||||
|
@ -378,8 +378,8 @@ export default class ItemModel extends BaseModel<Item> {
|
||||
|
||||
public async childrenCount(userId: Uuid, pathQuery: string = ''): Promise<number> {
|
||||
const query = this.childrenQuery(userId, pathQuery);
|
||||
query.groupBy('items.id');
|
||||
return query.count();
|
||||
const r = await query.countDistinct('items.id', { as: 'total' });
|
||||
return r[0].total;
|
||||
}
|
||||
|
||||
private async joplinItemPath(jopId: string): Promise<Item[]> {
|
||||
|
@ -20,8 +20,7 @@ export interface Pagination {
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
|
||||
interface PaginationQueryParams {
|
||||
export interface PaginationQueryParams {
|
||||
limit?: number;
|
||||
order_by?: string;
|
||||
order_dir?: string;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { beforeAllDb, afterAllTests, beforeEachDb, createItemTree, createUserAndSession } from '../../utils/testing/testUtils';
|
||||
import { beforeAllDb, afterAllTests, beforeEachDb, createItemTree, createUserAndSession, parseHtml } from '../../utils/testing/testUtils';
|
||||
import { execRequest } from '../../utils/testing/apiUtils';
|
||||
|
||||
describe('index_items', function() {
|
||||
@ -26,18 +26,27 @@ describe('index_items', function() {
|
||||
await createItemTree(user1.id, '', items);
|
||||
|
||||
// Just some basic tests to check that we're seeing at least the first
|
||||
// and last item of each page.
|
||||
// and last item of each page. And that the navigation bar is there with
|
||||
// the right elements.
|
||||
|
||||
{
|
||||
const response: string = await execRequest(session1.id, 'GET', 'items');
|
||||
const navLinks = parseHtml(response).querySelectorAll('.pagination-link');
|
||||
expect(response.includes('00000000000000000000000000000001.md')).toBe(true);
|
||||
expect(response.includes('00000000000000000000000000000100.md')).toBe(true);
|
||||
expect(navLinks.length).toBe(2);
|
||||
expect(navLinks[0].getAttribute('class')).toContain('is-current');
|
||||
expect(navLinks[1].getAttribute('class')).not.toContain('is-current');
|
||||
}
|
||||
|
||||
{
|
||||
const response: string = await execRequest(session1.id, 'GET', 'items', null, { query: { page: 2 } });
|
||||
const navLinks = parseHtml(response).querySelectorAll('.pagination-link');
|
||||
expect(response.includes('00000000000000000000000000000101.md')).toBe(true);
|
||||
expect(response.includes('00000000000000000000000000000150.md')).toBe(true);
|
||||
expect(navLinks.length).toBe(2);
|
||||
expect(navLinks[0].getAttribute('class')).not.toContain('is-current');
|
||||
expect(navLinks[1].getAttribute('class')).toContain('is-current');
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -3,61 +3,70 @@ import Router from '../../utils/Router';
|
||||
import { AppContext } from '../../utils/types';
|
||||
import { formParse } from '../../utils/requestUtils';
|
||||
import { ErrorNotFound } from '../../utils/errors';
|
||||
import { Item } from '../../db';
|
||||
import { createPaginationLinks, filterPaginationQueryParams, pageMaxSize, Pagination, PaginationOrder, PaginationOrderDir, requestPaginationOrder, validatePagination } from '../../models/utils/pagination';
|
||||
import { setQueryParameters } from '../../utils/urlUtils';
|
||||
import config from '../../config';
|
||||
import { formatDateTime } from '../../utils/time';
|
||||
import defaultView from '../../utils/defaultView';
|
||||
import { View } from '../../services/MustacheService';
|
||||
|
||||
function makeFilePagination(query: any): Pagination {
|
||||
const limit = Number(query.limit) || pageMaxSize;
|
||||
const order: PaginationOrder[] = requestPaginationOrder(query, 'name', PaginationOrderDir.ASC);
|
||||
const page: number = 'page' in query ? Number(query.page) : 1;
|
||||
|
||||
const output: Pagination = { limit, order, page };
|
||||
validatePagination(output);
|
||||
return output;
|
||||
}
|
||||
import { makeTablePagination, makeTableView, Row, Table, tablePartials } from '../../utils/views/table';
|
||||
const prettyBytes = require('pretty-bytes');
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.get('items', async (_path: SubPath, ctx: AppContext) => {
|
||||
// Query parameters that should be appended to pagination-related URLs
|
||||
const baseUrlQuery = filterPaginationQueryParams(ctx.query);
|
||||
const pagination = makeTablePagination(ctx.query);
|
||||
const paginatedItems = await ctx.models.item().children(ctx.owner.id, '', pagination, { fields: ['id', 'name', 'updated_time', 'mime_type', 'content_size'] });
|
||||
|
||||
const pagination = makeFilePagination(ctx.query);
|
||||
const owner = ctx.owner;
|
||||
const itemModel = ctx.models.item();
|
||||
const paginatedItems = await itemModel.children(owner.id, '', pagination, { fields: ['id', 'name', 'updated_time', 'mime_type'] });
|
||||
const pageCount = Math.ceil((await itemModel.childrenCount(owner.id, '')) / pagination.limit);
|
||||
const parentBaseUrl = itemModel.itemUrl();
|
||||
const paginationLinks = createPaginationLinks(pagination.page, pageCount, setQueryParameters(parentBaseUrl, { ...baseUrlQuery, 'page': 'PAGE_NUMBER' }));
|
||||
const table: Table = {
|
||||
baseUrl: ctx.models.item().itemUrl(),
|
||||
requestQuery: ctx.query,
|
||||
totalItemCount: await ctx.models.item().childrenCount(ctx.owner.id, ''),
|
||||
pagination,
|
||||
headers: [
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
stretch: true,
|
||||
},
|
||||
{
|
||||
name: 'content_size',
|
||||
label: 'Size',
|
||||
},
|
||||
{
|
||||
name: 'mime_type',
|
||||
label: 'Mime',
|
||||
},
|
||||
{
|
||||
name: 'updated_time',
|
||||
label: 'Timestamp',
|
||||
},
|
||||
],
|
||||
rows: paginatedItems.items.map(item => {
|
||||
const row: Row = [
|
||||
{
|
||||
value: item.name,
|
||||
stretch: true,
|
||||
url: `${config().baseUrl}/items/${item.id}/content`,
|
||||
},
|
||||
{
|
||||
value: prettyBytes(item.content_size),
|
||||
},
|
||||
{
|
||||
value: item.mime_type || 'binary',
|
||||
},
|
||||
{
|
||||
value: formatDateTime(item.updated_time),
|
||||
},
|
||||
];
|
||||
|
||||
async function itemToViewItem(item: Item): Promise<any> {
|
||||
return {
|
||||
name: item.name,
|
||||
url: `${config().baseUrl}/items/${item.id}/content`,
|
||||
type: 'file',
|
||||
icon: 'far fa-file',
|
||||
timestamp: formatDateTime(item.updated_time),
|
||||
mime: item.mime_type || 'binary',
|
||||
};
|
||||
}
|
||||
|
||||
const items: any[] = [];
|
||||
|
||||
for (const item of paginatedItems.items) {
|
||||
items.push(await itemToViewItem(item));
|
||||
}
|
||||
return row;
|
||||
}),
|
||||
};
|
||||
|
||||
const view: View = defaultView('items');
|
||||
view.content.paginatedFiles = { ...paginatedItems, items: items };
|
||||
view.content.paginationLinks = paginationLinks;
|
||||
view.content.itemTable = makeTableView(table),
|
||||
view.content.postUrl = `${config().baseUrl}/items`;
|
||||
view.cssFiles = ['index/items'];
|
||||
view.partials.push('pagination');
|
||||
view.partials = view.partials.concat(tablePartials());
|
||||
return view;
|
||||
});
|
||||
|
||||
|
111
packages/server/src/utils/views/table.ts
Normal file
111
packages/server/src/utils/views/table.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { createPaginationLinks, filterPaginationQueryParams, PageLink, pageMaxSize, Pagination, PaginationOrder, PaginationOrderDir, PaginationQueryParams, requestPaginationOrder, validatePagination } from '../../models/utils/pagination';
|
||||
import { setQueryParameters } from '../urlUtils';
|
||||
|
||||
const defaultSortOrder = PaginationOrderDir.ASC;
|
||||
|
||||
function headerIsSelectedClass(name: string, pagination: Pagination): string {
|
||||
const orderBy = pagination.order[0].by;
|
||||
return name === orderBy ? 'is-selected' : '';
|
||||
}
|
||||
|
||||
function headerSortIconDir(name: string, pagination: Pagination): string {
|
||||
const orderBy = pagination.order[0].by;
|
||||
const orderDir = orderBy === name ? pagination.order[0].dir : defaultSortOrder;
|
||||
return orderDir === PaginationOrderDir.ASC ? 'up' : 'down';
|
||||
|
||||
}
|
||||
|
||||
function headerNextOrder(name: string, pagination: Pagination): PaginationOrderDir {
|
||||
if (name !== pagination.order[0].by) return defaultSortOrder;
|
||||
return pagination.order[0].dir === PaginationOrderDir.ASC ? PaginationOrderDir.DESC : PaginationOrderDir.ASC;
|
||||
}
|
||||
|
||||
export interface Header {
|
||||
name: string;
|
||||
label: string;
|
||||
stretch?: boolean;
|
||||
}
|
||||
|
||||
interface HeaderView {
|
||||
label: string;
|
||||
sortLink: string;
|
||||
classNames: string[];
|
||||
iconDir: string;
|
||||
}
|
||||
|
||||
interface RowItem {
|
||||
value: string;
|
||||
url?: string;
|
||||
stretch?: boolean;
|
||||
}
|
||||
|
||||
export type Row = RowItem[];
|
||||
|
||||
interface RowItemView {
|
||||
value: string;
|
||||
classNames: string[];
|
||||
url: string;
|
||||
}
|
||||
|
||||
type RowView = RowItemView[];
|
||||
|
||||
export interface Table {
|
||||
headers: Header[];
|
||||
rows: Row[];
|
||||
baseUrl: string;
|
||||
requestQuery: any;
|
||||
totalItemCount: number;
|
||||
pagination: Pagination;
|
||||
}
|
||||
|
||||
export interface TableView {
|
||||
headers: HeaderView[];
|
||||
rows: RowView[];
|
||||
paginationLinks: PageLink[];
|
||||
}
|
||||
|
||||
export function makeTablePagination(query: any): Pagination {
|
||||
const limit = Number(query.limit) || pageMaxSize;
|
||||
const order: PaginationOrder[] = requestPaginationOrder(query, 'name', PaginationOrderDir.ASC);
|
||||
const page: number = 'page' in query ? Number(query.page) : 1;
|
||||
|
||||
const output: Pagination = { limit, order, page };
|
||||
validatePagination(output);
|
||||
return output;
|
||||
}
|
||||
|
||||
function makeHeaderView(header: Header, parentBaseUrl: string, baseUrlQuery: PaginationQueryParams, pagination: Pagination): HeaderView {
|
||||
return {
|
||||
label: header.label,
|
||||
sortLink: setQueryParameters(parentBaseUrl, { ...baseUrlQuery, 'order_by': header.name, 'order_dir': headerNextOrder(header.name, pagination) }),
|
||||
classNames: [header.stretch ? 'stretch' : 'nowrap', headerIsSelectedClass(header.name, pagination)],
|
||||
iconDir: headerSortIconDir(header.name, pagination),
|
||||
};
|
||||
}
|
||||
|
||||
function makeRowView(row: Row): RowView {
|
||||
return row.map(rowItem => {
|
||||
return {
|
||||
value: rowItem.value,
|
||||
classNames: [rowItem.stretch ? 'stretch' : 'nowrap'],
|
||||
url: rowItem.url,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function makeTableView(table: Table): TableView {
|
||||
const baseUrlQuery = filterPaginationQueryParams(table.requestQuery);
|
||||
const pagination = table.pagination;
|
||||
const pageCount = Math.ceil(table.totalItemCount / pagination.limit);
|
||||
const paginationLinks = createPaginationLinks(pagination.page, pageCount, setQueryParameters(table.baseUrl, { ...baseUrlQuery, 'page': 'PAGE_NUMBER' }));
|
||||
|
||||
return {
|
||||
headers: table.headers.map(h => makeHeaderView(h, table.baseUrl, baseUrlQuery, pagination)),
|
||||
rows: table.rows.map(r => makeRowView(r)),
|
||||
paginationLinks,
|
||||
};
|
||||
}
|
||||
|
||||
export function tablePartials(): string[] {
|
||||
return ['pagination', 'table', 'tableHeader', 'tableRowItem'];
|
||||
}
|
@ -2,28 +2,9 @@
|
||||
<input type="submit" name="delete_all_button" class="button is-danger" value="Delete all" />
|
||||
</form>
|
||||
|
||||
<table class="table is-fullwidth is-hoverable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="stretch">Name</th>
|
||||
<th class="nowrap">Mime</th>
|
||||
<th class="nowrap">Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#paginatedFiles.items}}
|
||||
<tr>
|
||||
<td class="stretch item-{{type}}">
|
||||
<a href="{{url}}"><span class="icon"><i class="{{icon}}"></i></span>{{name}}</a>
|
||||
</td>
|
||||
<td class="nowrap">{{mime}}</td>
|
||||
<td class="nowrap">{{timestamp}}</td>
|
||||
</tr>
|
||||
{{/paginatedFiles.items}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{>pagination}}
|
||||
{{#itemTable}}
|
||||
{{>table}}
|
||||
{{/itemTable}}
|
||||
|
||||
<script>
|
||||
onDocumentReady(function() {
|
||||
|
20
packages/server/src/views/partials/table.mustache
Normal file
20
packages/server/src/views/partials/table.mustache
Normal file
@ -0,0 +1,20 @@
|
||||
<table class="table is-fullwidth is-hoverable">
|
||||
<thead>
|
||||
<tr>
|
||||
{{#headers}}
|
||||
{{>tableHeader}}
|
||||
{{/headers}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#rows}}
|
||||
<tr>
|
||||
{{#.}}
|
||||
{{>tableRowItem}}
|
||||
{{/.}}
|
||||
</tr>
|
||||
{{/rows}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{>pagination}}
|
3
packages/server/src/views/partials/tableHeader.mustache
Normal file
3
packages/server/src/views/partials/tableHeader.mustache
Normal file
@ -0,0 +1,3 @@
|
||||
<th class="{{#classNames}}{{.}} {{/classNames}}">
|
||||
<a href="{{sortLink}}" class="sort-button">{{label}} <i class="fas fa-caret-{{iconDir}}"></i></a>
|
||||
</th>
|
3
packages/server/src/views/partials/tableRowItem.mustache
Normal file
3
packages/server/src/views/partials/tableRowItem.mustache
Normal file
@ -0,0 +1,3 @@
|
||||
<td class="{{#classNames}}{{.}} {{/classNames}}">
|
||||
{{#url}}<a href="{{.}}"></span>{{/url}}{{value}}</a>
|
||||
</td>
|
2520
packages/tools/package-lock.json
generated
2520
packages/tools/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user