mirror of
https://github.com/origranot/reduced.to.git
synced 2025-03-14 10:33:54 +00:00
Darkmift user table (#553)
* [user-table] added deps for feature * [user-table] added first component draft * [user-table] use of table in dashboard * [user-table] zindex removed disrupts click events * [user-table] WIP fetch data from backend * [user-table] TODO:fix generic type declaration * [user-table] fetches data,TODO refetch on reload * [user-table] fixed page reload not fetching data * [user-table] added filter input * [user-table] issue scope/serialize on table object * [user-table] applied my own table * [user-table] added types * [user-table] excluding local mock * [user-table] added server paginated table * [user-table] rewrite to use paginated table * [user-table] local /remote pagination hybrid * [user-table] applied some style * [user-table] removed mock ignore * [user-table] removed tanstack table * [user-table] deprecated file * [user-table] applied enum * [user-table] deprecated file * [user-table] removed unused style * [user-table] removed unused style * [user-table] commenting out logs * [user-table] removed file * [user-table] moved types under file * [user-table] minor css tweaks * [user-table] renamed files * [user-table] api call,file import,total changes * [user-table] added option to hide columns * [user-table] type change,total page fix value * [user-table] code refactoring * [user-table] refactor for optional attributes * [user-table] set sort to null * [user-table] deprecated unused file * [user-table] added util * [user-table] fixed maxpage ,disabled page input * [user-table] migrated pagination to reusable hook * [user-table] refactor code * [user-table] migrated fn back into file * [user-table] removeed comment * [user-table] cleanup of comments * [user-table] progress * [user-table] work towards abstrated cmp * wip: cleaning some stuff * fix: drawer hight * fix: use visibleTask instead of resource * fix: remove old code * feat: add document head for the users route * fix: columns orders * feat: add sortable option --------- Co-authored-by: darkmift <darkmift@gmail.com> Co-authored-by: orig <oriorigranot@gamil.com>
This commit is contained in:
19
apps/frontend/src/components/table/default-filter.tsx
Normal file
19
apps/frontend/src/components/table/default-filter.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { component$, Signal } from '@builder.io/qwik';
|
||||
|
||||
export interface FilterInputProps {
|
||||
filter: Signal<string>;
|
||||
onInput: (ev: InputEvent) => void;
|
||||
}
|
||||
|
||||
export const FilterInput = component$<FilterInputProps>(({ filter, onInput }) => {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={filter.value || ''}
|
||||
onInput$={(ev: InputEvent) => onInput(ev)}
|
||||
placeholder={`Search...`}
|
||||
style={{ width: '100%' }}
|
||||
class="input input-bordered w-full max-w-xs mb-5"
|
||||
/>
|
||||
);
|
||||
});
|
38
apps/frontend/src/components/table/pagination-actions.tsx
Normal file
38
apps/frontend/src/components/table/pagination-actions.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { Signal, component$ } from '@builder.io/qwik';
|
||||
import { ResponseData } from './table-server-pagination';
|
||||
|
||||
export interface PaginationActionsProps {
|
||||
tableData: Signal<ResponseData>;
|
||||
page: Signal<number>;
|
||||
limit: Signal<number>;
|
||||
maxPages: Signal<number>;
|
||||
isOnFirstPage: Signal<boolean>;
|
||||
isOnLastPage: Signal<boolean>;
|
||||
}
|
||||
|
||||
export const PaginationActions = component$((props: PaginationActionsProps) => {
|
||||
const { page, limit, tableData, maxPages, isOnFirstPage, isOnLastPage } = props;
|
||||
|
||||
return (
|
||||
<div class="flex gap-2">
|
||||
<div class="join">
|
||||
<button class="join-item btn btn-xs sm:btn-md" onClick$={() => (page.value = 1)} disabled={isOnFirstPage.value}>
|
||||
{'<<'}
|
||||
</button>
|
||||
<button class="join-item btn btn-xs sm:btn-md" onClick$={() => (page.value = page.value - 1)} disabled={isOnFirstPage.value}>
|
||||
{'<'}
|
||||
</button>
|
||||
<button class="join-item btn btn-xs sm:btn-md" onClick$={() => (page.value = page.value + 1)} disabled={isOnLastPage.value}>
|
||||
{'>'}
|
||||
</button>
|
||||
<button class="join-item btn btn-xs sm:btn-md" onClick$={() => (page.value = maxPages.value)} disabled={isOnLastPage.value}>
|
||||
{'>>'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="flex items-center gap-1">
|
||||
Page {page.value} of {maxPages.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
190
apps/frontend/src/components/table/table-server-pagination.tsx
Normal file
190
apps/frontend/src/components/table/table-server-pagination.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
import { component$, useSignal, $, PropFunction, useVisibleTask$ } from '@builder.io/qwik';
|
||||
import { FilterInput } from './default-filter';
|
||||
import { authorizedFetch } from '../../shared/auth.service';
|
||||
import { PaginationActions } from './pagination-actions';
|
||||
import { HiChevronDownOutline, HiChevronUpDownOutline, HiChevronUpOutline } from '@qwikest/icons/heroicons';
|
||||
|
||||
export enum SortOrder {
|
||||
DESC = 'desc',
|
||||
ASC = 'asc',
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
limit: number;
|
||||
page: number;
|
||||
filter: string;
|
||||
sort: Record<string, SortOrder>;
|
||||
}
|
||||
|
||||
export type PaginationFetcher = ({ limit, page, filter, sort }: PaginationParams) => Promise<ResponseData>;
|
||||
|
||||
export type OptionalHeader = {
|
||||
displayName?: string;
|
||||
classNames?: string;
|
||||
hide?: boolean;
|
||||
sortable?: boolean;
|
||||
};
|
||||
|
||||
export type Columns = Record<string, OptionalHeader>;
|
||||
|
||||
export interface TableServerPaginationParams {
|
||||
endpoint: string;
|
||||
columns: Columns;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface ResponseData {
|
||||
total: number;
|
||||
data: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export const serializeQueryUserPaginationParams = (paginationParams: PaginationParams) => {
|
||||
const paramsForQuery = new URLSearchParams();
|
||||
paramsForQuery.set('limit', paginationParams.limit.toString());
|
||||
paramsForQuery.set('page', paginationParams.page.toString());
|
||||
|
||||
if (paginationParams.filter) {
|
||||
paramsForQuery.set('filter', paginationParams.filter);
|
||||
}
|
||||
|
||||
if (paginationParams.sort) {
|
||||
Object.entries(paginationParams.sort).forEach(([key, value]) => {
|
||||
paramsForQuery.set(`sort[${key}]`, value);
|
||||
});
|
||||
}
|
||||
|
||||
return paramsForQuery.toString();
|
||||
};
|
||||
|
||||
export const TableServerPagination = component$((props: TableServerPaginationParams) => {
|
||||
const filter = useSignal('');
|
||||
const currentPage = useSignal(1);
|
||||
const limit = useSignal(10);
|
||||
const sortSignal = useSignal<Record<string, SortOrder>>({});
|
||||
const tableData = useSignal<ResponseData>({ total: 0, data: [] });
|
||||
const maxPages = useSignal(0);
|
||||
|
||||
const isOnFirstPage = useSignal(true);
|
||||
const isOnLastPage = useSignal(true);
|
||||
|
||||
const isLoading = useSignal(true);
|
||||
|
||||
const fetchTableData: PropFunction<PaginationFetcher> = $(async (paginationParams: PaginationParams) => {
|
||||
const queryParams = serializeQueryUserPaginationParams(paginationParams);
|
||||
const data = await authorizedFetch(`${props.endpoint}?${queryParams}`);
|
||||
const response = (await data.json()) as ResponseData;
|
||||
if (!response || !response.data) {
|
||||
console.warn('Server response is not valid', response);
|
||||
|
||||
response.total = 0;
|
||||
response.data = [];
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
const onFilterInputChange = $(async (ev: InputEvent) => {
|
||||
filter.value = (ev.target as HTMLInputElement).value;
|
||||
currentPage.value = 1;
|
||||
});
|
||||
|
||||
useVisibleTask$(async ({ track }) => {
|
||||
track(() => currentPage.value);
|
||||
track(() => limit.value);
|
||||
track(() => filter.value);
|
||||
track(() => sortSignal.value);
|
||||
|
||||
// Fetch data
|
||||
const result = await fetchTableData({
|
||||
page: currentPage.value,
|
||||
limit: limit.value,
|
||||
filter: filter.value,
|
||||
sort: sortSignal.value,
|
||||
});
|
||||
|
||||
tableData.value = result;
|
||||
maxPages.value = Math.ceil(result.total / limit.value || 1);
|
||||
|
||||
// Update isOnFirstPage and isOnLastPage
|
||||
isOnFirstPage.value = currentPage.value === 1;
|
||||
isOnLastPage.value = currentPage.value >= maxPages.value;
|
||||
|
||||
isLoading.value = false;
|
||||
});
|
||||
|
||||
const sortColumn = (columnName: string) =>
|
||||
$(() => {
|
||||
if (!props.columns[columnName].sortable) return;
|
||||
|
||||
const currentSortOrder = sortSignal.value[columnName];
|
||||
if (!currentSortOrder || currentSortOrder === SortOrder.DESC) {
|
||||
sortSignal.value = { [columnName]: SortOrder.ASC };
|
||||
} else {
|
||||
sortSignal.value = { [columnName]: SortOrder.DESC };
|
||||
}
|
||||
|
||||
// Reset the page to the first page when sorting
|
||||
currentPage.value = 1;
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="flex flex-col justify-start">
|
||||
<FilterInput filter={filter} onInput={onFilterInputChange} />
|
||||
{isLoading.value ? ( // Show loader covering the entire table
|
||||
<div class="animate-pulse">
|
||||
<div class="h-4 bg-base-200 mb-6 mt-2 rounded"></div>
|
||||
{Array.from({ length: 12 }).map(() => (
|
||||
<div class="h-4 bg-base-200 mb-6 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div class="overflow-x-auto">
|
||||
<table id="table" class="table table-zebra table-fixed whitespace-nowrap">
|
||||
<thead>
|
||||
<tr>
|
||||
{Object.keys(props.columns).map((columnName, idx) => {
|
||||
if (props.columns[columnName].hide) return;
|
||||
return (
|
||||
<th class={props.columns[columnName].classNames ?? ''} onClick$={sortColumn(columnName)} key={idx}>
|
||||
{props.columns[columnName].displayName ?? columnName}{' '}
|
||||
{props.columns[columnName].sortable &&
|
||||
(!sortSignal.value[columnName] ? (
|
||||
<HiChevronUpDownOutline class="inline align-text-bottom text-base" />
|
||||
) : sortSignal.value[columnName] === SortOrder.ASC ? (
|
||||
<HiChevronUpOutline class="inline align-text-bottom" />
|
||||
) : (
|
||||
<HiChevronDownOutline class="inline align-text-bottom" />
|
||||
))}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableData.value.data.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{Object.keys(props.columns).map((columnName, idx) => {
|
||||
if (props.columns[columnName].hide) return;
|
||||
return <td key={idx}>{row[columnName]?.toString()}</td>;
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex pt-5 text-sm font-bold">
|
||||
<PaginationActions
|
||||
tableData={tableData}
|
||||
limit={limit}
|
||||
page={currentPage}
|
||||
maxPages={maxPages}
|
||||
isOnFirstPage={isOnFirstPage}
|
||||
isOnLastPage={isOnLastPage}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
@ -1,53 +0,0 @@
|
||||
import { component$ } from '@builder.io/qwik';
|
||||
import type { DocumentHead } from '@builder.io/qwik-city';
|
||||
|
||||
export default component$(() => {
|
||||
return (
|
||||
<>
|
||||
<h1>Admin panel</h1>
|
||||
<p class="text-red-700">//TODO: Implement a table of users</p>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const head: DocumentHead = {
|
||||
title: 'Reduced.to | Admin panel',
|
||||
meta: [
|
||||
{
|
||||
name: 'title',
|
||||
content: 'Reduced.to | Admin panel',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
content: 'Reduced.to | Admin panel, see other users, and more!',
|
||||
},
|
||||
{
|
||||
property: 'og:type',
|
||||
content: 'website',
|
||||
},
|
||||
{
|
||||
property: 'og:url',
|
||||
content: 'https://reduced.to/dashboard/admin',
|
||||
},
|
||||
{
|
||||
property: 'og:title',
|
||||
content: 'Reduced.to | Admin panel',
|
||||
},
|
||||
{
|
||||
property: 'og:description',
|
||||
content: 'Reduced.to | Admin panel, see other users, and more!',
|
||||
},
|
||||
{
|
||||
property: 'twitter:card',
|
||||
content: 'summary',
|
||||
},
|
||||
{
|
||||
property: 'twitter:title',
|
||||
content: 'Reduced.to | Admin panel',
|
||||
},
|
||||
{
|
||||
property: 'twitter:description',
|
||||
content: 'Reduced.to | Admin panel, see other users, and more!',
|
||||
},
|
||||
],
|
||||
};
|
60
apps/frontend/src/routes/dashboard/admin/users/index.tsx
Normal file
60
apps/frontend/src/routes/dashboard/admin/users/index.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { component$ } from '@builder.io/qwik';
|
||||
import { Columns, TableServerPagination } from '../../../../components/table/table-server-pagination';
|
||||
import { DocumentHead } from '@builder.io/qwik-city';
|
||||
|
||||
export default component$(() => {
|
||||
const columns: Columns = {
|
||||
name: { displayName: 'Name', classNames: 'w-1/4', sortable: true },
|
||||
email: { displayName: 'Email', classNames: 'w-1/4', sortable: true },
|
||||
role: { displayName: 'Role', classNames: 'w-1/4', sortable: true },
|
||||
verified: { displayName: 'Verified', classNames: 'w-1/4' },
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableServerPagination endpoint={`${process.env.API_DOMAIN}/api/v1/users`} columns={columns} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const head: DocumentHead = {
|
||||
title: 'Reduced.to | Admin Dashboard - Users',
|
||||
meta: [
|
||||
{
|
||||
name: 'title',
|
||||
content: 'Reduced.to | Admin Dashboard - Users',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
content: 'Reduced.to | Admin Dashboard - See all users',
|
||||
},
|
||||
{
|
||||
property: 'og:type',
|
||||
content: 'website',
|
||||
},
|
||||
{
|
||||
property: 'og:url',
|
||||
content: 'https://reduced.to/dashboard',
|
||||
},
|
||||
{
|
||||
property: 'og:title',
|
||||
content: 'Reduced.to | Admin Dashboard - Users',
|
||||
},
|
||||
{
|
||||
property: 'og:description',
|
||||
content: 'Reduced.to | Admin Dashboard - See all users',
|
||||
},
|
||||
{
|
||||
property: 'twitter:card',
|
||||
content: 'summary',
|
||||
},
|
||||
{
|
||||
property: 'twitter:title',
|
||||
content: 'Reduced.to | Admin Dashboard - Users',
|
||||
},
|
||||
{
|
||||
property: 'twitter:description',
|
||||
content: 'Reduced.to | Admin Dashboard - See all users',
|
||||
},
|
||||
],
|
||||
};
|
@ -18,7 +18,7 @@ export default component$(() => {
|
||||
const toggleDrawer = $(() => (isDrawerOpen.value = !isDrawerOpen.value));
|
||||
|
||||
return (
|
||||
<div class="drawer lg:drawer-open h-[calc(100vh-64px)]">
|
||||
<div class="drawer lg:drawer-open min-h-[calc(100vh-64px)]">
|
||||
<input id="drawer" type="checkbox" class="drawer-toggle" checked={isDrawerOpen.value} onChange$={toggleDrawer} />
|
||||
<div class="drawer-content w-100vh m-5">
|
||||
<Slot />
|
||||
@ -114,7 +114,8 @@ export default component$(() => {
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
class={`${location.url.pathname.slice(0, -1) === '/dashboard/admin/users' ? 'active' : ''} !cursor-not-allowed`}
|
||||
href="/dashboard/admin/users"
|
||||
class={`${location.url.pathname.slice(0, -1) === '/dashboard/admin/users' ? 'active' : ''}`}
|
||||
onClick$={toggleDrawer}
|
||||
>
|
||||
<svg
|
||||
@ -132,7 +133,6 @@ export default component$(() => {
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium">Users</span>
|
||||
<span class="badge badge-neutral">Soon</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
15
package-lock.json
generated
15
package-lock.json
generated
@ -27,6 +27,7 @@
|
||||
"@novu/node": "^0.19.0",
|
||||
"@origranot/ts-logger": "^1.12.0",
|
||||
"@prisma/client": "^5.2.0",
|
||||
"@qwikest/icons": "^0.0.13",
|
||||
"axios": "^1.5.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cache-manager": "^5.2.3",
|
||||
@ -2323,7 +2324,6 @@
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@builder.io/qwik/-/qwik-1.2.14.tgz",
|
||||
"integrity": "sha512-z+NFSNrAKjCVV/9d2f50Y/AthcHdlH4Hbltyjjl/0wOv4FUR3F/Ke9wz9eIhDYQ6rzFqzdav2/yTiip05El1zA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.1.2"
|
||||
},
|
||||
@ -3903,7 +3903,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.0.0.tgz",
|
||||
"integrity": "sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
@ -7391,6 +7390,17 @@
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
|
||||
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="
|
||||
},
|
||||
"node_modules/@qwikest/icons": {
|
||||
"version": "0.0.13",
|
||||
"resolved": "https://registry.npmjs.org/@qwikest/icons/-/icons-0.0.13.tgz",
|
||||
"integrity": "sha512-e0wY8vmx0nDSUiuCATlk+ojTvdBV4txIGHHWjZW5SRkv4XB8H9+3WSDcLPz0ItUdRyzcrohE9k2jtQI/98aRPA==",
|
||||
"engines": {
|
||||
"node": ">=15.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@builder.io/qwik": ">=1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/bloom": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
|
||||
@ -24136,7 +24146,6 @@
|
||||
"version": "5.26.2",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-5.26.2.tgz",
|
||||
"integrity": "sha512-a4PDLQgLTPHVzOK+x3F79/M4GtyYPl+aX9AAK7aQxpwxDwCqkeZCScy7Gk5kWT3JtdFq1uhO3uZJdLtHI4dK9A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@fastify/busboy": "^2.0.0"
|
||||
},
|
||||
|
@ -14,6 +14,7 @@
|
||||
"@docusaurus/module-type-aliases": "^2.4.1",
|
||||
"@nestjs/schematics": "^10.0.1",
|
||||
"@nestjs/testing": "^10.0.2",
|
||||
"@nx/eslint": "17.0.1",
|
||||
"@nx/eslint-plugin": "17.0.1",
|
||||
"@nx/jest": "17.0.1",
|
||||
"@nx/js": "17.0.1",
|
||||
@ -58,8 +59,7 @@
|
||||
"undici": "^5.26.2",
|
||||
"vite": "^4.4.9",
|
||||
"vite-tsconfig-paths": "~4.2.0",
|
||||
"vitest": "~0.32.0",
|
||||
"@nx/eslint": "17.0.1"
|
||||
"vitest": "~0.32.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^2.4.1",
|
||||
@ -76,6 +76,7 @@
|
||||
"@novu/node": "^0.19.0",
|
||||
"@origranot/ts-logger": "^1.12.0",
|
||||
"@prisma/client": "^5.2.0",
|
||||
"@qwikest/icons": "^0.0.13",
|
||||
"axios": "^1.5.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cache-manager": "^5.2.3",
|
||||
|
Reference in New Issue
Block a user