mirror of
https://github.com/flarum/core.git
synced 2025-08-06 08:27:42 +02:00
feat: extension list UI (#4066)
This commit is contained in:
@@ -16,13 +16,14 @@ import MailPage from './components/MailPage';
|
||||
import AdvancedPage from './components/AdvancedPage';
|
||||
import PermissionsPage from './components/PermissionsPage';
|
||||
|
||||
export type Extension = {
|
||||
export interface Extension {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
icon?: {
|
||||
name: string;
|
||||
[key: string]: string;
|
||||
};
|
||||
links: {
|
||||
authors?: {
|
||||
@@ -44,7 +45,7 @@ export type Extension = {
|
||||
};
|
||||
};
|
||||
require?: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
export enum DatabaseDriver {
|
||||
MySQL = 'MySQL',
|
||||
|
@@ -19,6 +19,7 @@ import CreateUserModal from './CreateUserModal';
|
||||
import Icon from '../../common/components/Icon';
|
||||
import Input from '../../common/components/Input';
|
||||
import GambitsAutocompleteDropdown from '../../common/components/GambitsAutocompleteDropdown';
|
||||
import Pagination from '../../common/components/Pagination';
|
||||
|
||||
type ColumnData = {
|
||||
/**
|
||||
@@ -78,11 +79,6 @@ export default class UserListPage extends AdminPage {
|
||||
*/
|
||||
private pageData: User[] | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Are there more users available?
|
||||
*/
|
||||
private moreData: boolean = false;
|
||||
|
||||
private isLoadingPage: boolean = false;
|
||||
|
||||
oninit(vnode: Mithril.Vnode<IPageAttrs, this>) {
|
||||
@@ -160,76 +156,13 @@ export default class UserListPage extends AdminPage {
|
||||
{/* Loading spinner that shows when a new page is being loaded */}
|
||||
{this.isLoadingPage && <LoadingIndicator size="large" />}
|
||||
</section>,
|
||||
<nav className="UserListPage-gridPagination">
|
||||
<Button
|
||||
disabled={this.pageNumber === 0}
|
||||
title={app.translator.trans('core.admin.users.pagination.first_page_button')}
|
||||
onclick={this.goToPage.bind(this, 1)}
|
||||
icon="fas fa-step-backward"
|
||||
className="Button Button--icon UserListPage-firstPageBtn"
|
||||
/>
|
||||
<Button
|
||||
disabled={this.pageNumber === 0}
|
||||
title={app.translator.trans('core.admin.users.pagination.back_button')}
|
||||
onclick={this.previousPage.bind(this)}
|
||||
icon="fas fa-chevron-left"
|
||||
className="Button Button--icon UserListPage-backBtn"
|
||||
/>
|
||||
<span className="UserListPage-pageNumber">
|
||||
{app.translator.trans('core.admin.users.pagination.page_counter', {
|
||||
// https://technology.blog.gov.uk/2020/02/24/why-the-gov-uk-design-system-team-changed-the-input-type-for-numbers/
|
||||
current: (
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={this.loadingPageNumber + 1}
|
||||
aria-label={extractText(app.translator.trans('core.admin.users.pagination.go_to_page_textbox_a11y_label'))}
|
||||
autocomplete="off"
|
||||
className="FormControl UserListPage-pageNumberInput"
|
||||
onchange={(e: InputEvent) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
let pageNumber = parseInt(target.value);
|
||||
|
||||
if (isNaN(pageNumber)) {
|
||||
// Invalid value, reset to current page
|
||||
target.value = (this.pageNumber + 1).toString();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pageNumber < 1) {
|
||||
// Lower constraint
|
||||
pageNumber = 1;
|
||||
} else if (pageNumber > this.getTotalPageCount()) {
|
||||
// Upper constraint
|
||||
pageNumber = this.getTotalPageCount();
|
||||
}
|
||||
|
||||
target.value = pageNumber.toString();
|
||||
|
||||
this.goToPage(pageNumber);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
currentNum: this.pageNumber + 1,
|
||||
total: this.getTotalPageCount(),
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
disabled={!this.moreData}
|
||||
title={app.translator.trans('core.admin.users.pagination.next_button')}
|
||||
onclick={this.nextPage.bind(this)}
|
||||
icon="fas fa-chevron-right"
|
||||
className="Button Button--icon UserListPage-nextBtn"
|
||||
/>
|
||||
<Button
|
||||
disabled={!this.moreData}
|
||||
title={app.translator.trans('core.admin.users.pagination.last_page_button')}
|
||||
onclick={this.goToPage.bind(this, this.getTotalPageCount())}
|
||||
icon="fas fa-step-forward"
|
||||
className="Button Button--icon UserListPage-lastPageBtn"
|
||||
/>
|
||||
</nav>,
|
||||
<Pagination
|
||||
currentPage={this.pageNumber + 1}
|
||||
loadingPageNumber={this.loadingPageNumber + 1}
|
||||
total={this.userCount}
|
||||
perPage={this.numPerPage}
|
||||
onChange={this.goToPage.bind(this)}
|
||||
/>,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -482,9 +415,6 @@ export default class UserListPage extends AdminPage {
|
||||
},
|
||||
})
|
||||
.then((apiData) => {
|
||||
// Next link won't be present if there's no more data
|
||||
this.moreData = !!apiData.payload?.links?.next;
|
||||
|
||||
let data = apiData;
|
||||
|
||||
// @ts-ignore
|
||||
@@ -509,16 +439,6 @@ export default class UserListPage extends AdminPage {
|
||||
});
|
||||
}
|
||||
|
||||
nextPage() {
|
||||
this.isLoadingPage = true;
|
||||
this.loadPage(this.pageNumber + 1);
|
||||
}
|
||||
|
||||
previousPage() {
|
||||
this.isLoadingPage = true;
|
||||
this.loadPage(this.pageNumber - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param page The **1-based** page number
|
||||
*/
|
||||
@@ -532,6 +452,7 @@ export default class UserListPage extends AdminPage {
|
||||
const params = new URLSearchParams(search?.[1] ?? '');
|
||||
|
||||
params.set('page', `${pageNumber}`);
|
||||
window.location.hash = search?.[0] + '?' + params.toString();
|
||||
// window.location.hash = search?.[0] + '?' + params.toString();
|
||||
window.history.replaceState(null, '', search?.[0] + '?' + params.toString());
|
||||
}
|
||||
}
|
||||
|
@@ -4,6 +4,11 @@ import Model, { ModelData, SavedModelData } from './Model';
|
||||
import GambitManager from './GambitManager';
|
||||
|
||||
export interface MetaInformation {
|
||||
page?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
total?: number;
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
@@ -31,6 +31,7 @@ import './utils/patchMithril';
|
||||
import './utils/classList';
|
||||
import './utils/extractText';
|
||||
import './utils/formatNumber';
|
||||
import './utils/formatAmount';
|
||||
import './utils/mapRoutes';
|
||||
import './utils/withAttr';
|
||||
import './utils/focusTrap';
|
||||
|
@@ -14,7 +14,6 @@ export interface IInputAttrs extends ComponentAttrs {
|
||||
clearable?: boolean;
|
||||
clearLabel?: string;
|
||||
loading?: boolean;
|
||||
inputClassName?: string;
|
||||
onchange?: (value: string) => void;
|
||||
value?: string;
|
||||
stream?: Stream<string>;
|
||||
|
94
framework/core/js/src/common/components/Pagination.tsx
Normal file
94
framework/core/js/src/common/components/Pagination.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import Component, { ComponentAttrs } from '../Component';
|
||||
import Button from './Button';
|
||||
import app from '../../admin/app';
|
||||
import extractText from '../utils/extractText';
|
||||
|
||||
export interface IPaginationInterface extends ComponentAttrs {
|
||||
total: number;
|
||||
perPage: number;
|
||||
currentPage: number;
|
||||
loadingPageNumber?: number;
|
||||
onChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export default class Pagination<CustomAttrs extends IPaginationInterface = IPaginationInterface> extends Component<CustomAttrs> {
|
||||
view() {
|
||||
const { total, perPage, currentPage, loadingPageNumber, onChange } = this.attrs;
|
||||
|
||||
const totalPageCount = Math.ceil(total / perPage);
|
||||
const moreData = totalPageCount > currentPage;
|
||||
|
||||
return (
|
||||
<nav className="Pagination">
|
||||
<Button
|
||||
disabled={currentPage === 1}
|
||||
title={app.translator.trans('core.admin.users.pagination.first_page_button')}
|
||||
onclick={() => onChange(1)}
|
||||
icon="fas fa-step-backward"
|
||||
className="Button Button--icon Pagination-first"
|
||||
/>
|
||||
<Button
|
||||
disabled={currentPage === 1}
|
||||
title={app.translator.trans('core.admin.users.pagination.back_button')}
|
||||
onclick={() => onChange(currentPage - 1)}
|
||||
icon="fas fa-chevron-left"
|
||||
className="Button Button--icon Pagination-back"
|
||||
/>
|
||||
<span className="Pagination-pageNumber">
|
||||
{app.translator.trans('core.admin.users.pagination.page_counter', {
|
||||
// https://technology.blog.gov.uk/2020/02/24/why-the-gov-uk-design-system-team-changed-the-input-type-for-numbers/
|
||||
current: (
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={loadingPageNumber ?? currentPage}
|
||||
aria-label={extractText(app.translator.trans('core.admin.users.pagination.go_to_page_textbox_a11y_label'))}
|
||||
autocomplete="off"
|
||||
className="FormControl Pagination-input"
|
||||
onchange={(e: InputEvent) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
let pageNumber = parseInt(target.value);
|
||||
|
||||
if (isNaN(pageNumber)) {
|
||||
// Invalid value, reset to current page
|
||||
target.value = (currentPage + 1).toString();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pageNumber < 1) {
|
||||
// Lower constraint
|
||||
pageNumber = 1;
|
||||
} else if (pageNumber > totalPageCount) {
|
||||
// Upper constraint
|
||||
pageNumber = totalPageCount;
|
||||
}
|
||||
|
||||
target.value = pageNumber.toString();
|
||||
|
||||
onChange(pageNumber);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
currentNum: currentPage,
|
||||
total: totalPageCount,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
disabled={!moreData}
|
||||
title={app.translator.trans('core.admin.users.pagination.next_button')}
|
||||
onclick={() => onChange(currentPage + 1)}
|
||||
icon="fas fa-chevron-right"
|
||||
className="Button Button--icon Pagination-next"
|
||||
/>
|
||||
<Button
|
||||
disabled={!moreData}
|
||||
title={app.translator.trans('core.admin.users.pagination.last_page_button')}
|
||||
onclick={() => onChange(totalPageCount)}
|
||||
icon="fas fa-step-forward"
|
||||
className="Button Button--icon Pagination-last"
|
||||
/>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
}
|
@@ -126,9 +126,9 @@ export default class SearchModal<CustomAttrs extends ISearchModalAttrs = ISearch
|
||||
|
||||
tabs(): JSX.Element {
|
||||
return (
|
||||
<div className="SearchModal-tabs">
|
||||
<div className="SearchModal-tabs-nav">{this.tabItems().toArray()}</div>
|
||||
<div className="SearchModal-tabs-content">{this.activeTabItems().toArray()}</div>
|
||||
<div className="Tabs">
|
||||
<div className="Tabs-nav">{this.tabItems().toArray()}</div>
|
||||
<div className="Tabs-content SearchModal-tabs-content">{this.activeTabItems().toArray()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -47,6 +47,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
|
||||
protected location!: PaginationLocation;
|
||||
public pageSize: number | null;
|
||||
public totalItems: number | null = null;
|
||||
|
||||
protected pages: Page<T>[] = [];
|
||||
protected params: P = {} as P;
|
||||
@@ -54,6 +55,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
protected initialLoading: boolean = false;
|
||||
protected loadingPrev: boolean = false;
|
||||
protected loadingNext: boolean = false;
|
||||
protected loadingPage: boolean = false;
|
||||
|
||||
protected constructor(params: P = {} as P, page: number = 1, pageSize: number | null = null) {
|
||||
this.params = params;
|
||||
@@ -139,12 +141,19 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
}
|
||||
|
||||
return app.store.find<T[]>(this.type, params).then((results) => {
|
||||
const usedPerPage = results.payload?.meta?.perPage;
|
||||
const usedTotal = results.payload?.meta?.page?.total;
|
||||
|
||||
/*
|
||||
* If this state does not rely on a preloaded API document to know the page size,
|
||||
* then there is no initial list, and therefore the page size can be taken from subsequent requests.
|
||||
*/
|
||||
if (!this.pageSize) {
|
||||
this.pageSize = results.payload?.meta?.perPage || PaginatedListState.DEFAULT_PAGE_SIZE;
|
||||
if (!this.pageSize || (usedPerPage && this.pageSize !== usedPerPage)) {
|
||||
this.pageSize = usedPerPage || PaginatedListState.DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
|
||||
if (!this.totalItems || (usedTotal && this.totalItems !== usedTotal)) {
|
||||
this.totalItems = usedTotal || null;
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -187,14 +196,25 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
|
||||
this.clear();
|
||||
|
||||
return this.goto(page);
|
||||
}
|
||||
|
||||
public goto(page: number): Promise<void> {
|
||||
this.location = { page };
|
||||
|
||||
return this.loadPage()
|
||||
if (!this.initialLoading) {
|
||||
this.loadingPage = true;
|
||||
}
|
||||
|
||||
return this.loadPage(page)
|
||||
.then((results) => {
|
||||
this.pages = [];
|
||||
this.parseResults(this.location.page, results);
|
||||
})
|
||||
.finally(() => (this.initialLoading = false));
|
||||
.finally(() => {
|
||||
this.initialLoading = false;
|
||||
this.loadingPage = false;
|
||||
});
|
||||
}
|
||||
|
||||
public getPages(): Page<T>[] {
|
||||
@@ -205,7 +225,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
}
|
||||
|
||||
public isLoading(): boolean {
|
||||
return this.initialLoading || this.loadingNext || this.loadingPrev;
|
||||
return this.initialLoading || this.loadingNext || this.loadingPrev || this.loadingPage;
|
||||
}
|
||||
public isInitialLoading(): boolean {
|
||||
return this.initialLoading;
|
||||
@@ -322,18 +342,23 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
}
|
||||
|
||||
changeSort(sort: string) {
|
||||
let currentSort: string | undefined;
|
||||
|
||||
if (sort === Object.keys(this.sortMap())[0]) {
|
||||
currentSort = undefined;
|
||||
} else {
|
||||
currentSort = sort;
|
||||
}
|
||||
|
||||
this.refreshParams(
|
||||
{
|
||||
...this.params,
|
||||
sort: currentSort,
|
||||
sort: sort,
|
||||
},
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
changeFilter(key: string, value: string) {
|
||||
this.refreshParams(
|
||||
{
|
||||
...this.params,
|
||||
filter: {
|
||||
...this.params.filter,
|
||||
[key]: value,
|
||||
},
|
||||
},
|
||||
1
|
||||
);
|
||||
|
13
framework/core/js/src/common/utils/formatAmount.ts
Normal file
13
framework/core/js/src/common/utils/formatAmount.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export default function formatAmount(size: number): string {
|
||||
const units = ['K', 'M', 'B'];
|
||||
|
||||
for (let i = units.length - 1; i >= 0; i--) {
|
||||
const decimal = Math.pow(1000, i + 1);
|
||||
|
||||
if (size >= decimal) {
|
||||
return (size / decimal).toFixed(1).replace(/\.0$/, '') + units[i];
|
||||
}
|
||||
}
|
||||
|
||||
return size.toString();
|
||||
}
|
Reference in New Issue
Block a user