feat: implement barebone zola templates
This commit is contained in:
parent
9c20f5ed2e
commit
f99a9ae2ac
198 changed files with 2434 additions and 227991 deletions
43
_src/components/Table/Filters/NumberFilter.tsx
Normal file
43
_src/components/Table/Filters/NumberFilter.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
onChange: (value: [string, number | null]) => void;
|
||||
keyData: string;
|
||||
}
|
||||
|
||||
export default function NumberInput({ keyData, onChange }: Props): JSX.Element {
|
||||
const [value, setValue] = useState<string>('');
|
||||
const [operator, setOperator] = useState<string>('=');
|
||||
|
||||
useEffect(() => {
|
||||
onChange([operator, value === '' ? null : parseFloat(value)]);
|
||||
}, [value, operator]);
|
||||
|
||||
return (
|
||||
<div className="hstack">
|
||||
<select
|
||||
name={`number-select-${keyData}`}
|
||||
id={`number-select-${keyData}`}
|
||||
defaultValue={'='}
|
||||
onChange={({ target }) => {
|
||||
setOperator(target.value);
|
||||
}}
|
||||
>
|
||||
<option value="=">=</option>
|
||||
<option value=">">{'>'}</option>
|
||||
<option value="<">{'<'}</option>
|
||||
<option value=">=">{'>='}</option>
|
||||
<option value="<=">{'<='}</option>
|
||||
</select>
|
||||
<input
|
||||
name={`number-input-${keyData}`}
|
||||
id="foo"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
onChange={({ target }) => {
|
||||
setValue(target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
_src/components/Table/Filters/SelectFilter.tsx
Normal file
41
_src/components/Table/Filters/SelectFilter.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import type { DataItem, Value } from '../types';
|
||||
import SelectInput from '@components/Inputs/SelectInput';
|
||||
|
||||
interface Props {
|
||||
onChange: (value: Value) => void;
|
||||
data: DataItem[];
|
||||
keyData: string;
|
||||
isMultiple?: boolean;
|
||||
}
|
||||
|
||||
export default function SelectFilter({
|
||||
data,
|
||||
keyData,
|
||||
isMultiple = false,
|
||||
onChange,
|
||||
}: Props): JSX.Element {
|
||||
const options = useMemo(() => {
|
||||
let options = [];
|
||||
|
||||
if (isMultiple) {
|
||||
options = data.flatMap((item) => item[keyData]);
|
||||
} else {
|
||||
options = data.map((item) => item[keyData]);
|
||||
}
|
||||
|
||||
options = [...new Set(options)];
|
||||
|
||||
options = options.map((item) => ({ label: item, value: item }));
|
||||
|
||||
return options;
|
||||
}, [data, keyData]);
|
||||
|
||||
return (
|
||||
<SelectInput
|
||||
options={options}
|
||||
isMultiple={isMultiple}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
64
_src/components/Table/Filters/index.tsx
Normal file
64
_src/components/Table/Filters/index.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { HeaderType, type Filter, type Value } from '../types.ts';
|
||||
|
||||
export { default as SelectFilter } from './SelectFilter.tsx';
|
||||
export { default as NumberFilter } from './NumberFilter.tsx';
|
||||
|
||||
const filterString = (value: string, data: string): boolean => {
|
||||
return data.toLowerCase().search(value.toLowerCase()) !== -1;
|
||||
};
|
||||
const filterNumber = (value: Value, data: any): boolean => {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(
|
||||
'Value should be an array in the form of [operator: string, value: number]',
|
||||
);
|
||||
}
|
||||
|
||||
const [operator, numberValue] = value;
|
||||
|
||||
if (numberValue === null) return true;
|
||||
|
||||
switch (operator) {
|
||||
case '=':
|
||||
return data === numberValue;
|
||||
case '<':
|
||||
return data < numberValue;
|
||||
case '>':
|
||||
return data > numberValue;
|
||||
case '<=':
|
||||
return data <= numberValue;
|
||||
case '>=':
|
||||
return data >= numberValue;
|
||||
|
||||
default:
|
||||
return data === numberValue;
|
||||
}
|
||||
};
|
||||
const filterSelect = (value: Value, data: any): boolean => {
|
||||
return data === value;
|
||||
};
|
||||
const filterMultiple = (value: Value, data: any): boolean => {
|
||||
if (value === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return data.includes(value);
|
||||
}
|
||||
|
||||
return value.every((filter: string | number) => data.includes(filter));
|
||||
};
|
||||
|
||||
export const resolveFilterByType = (filter: Filter, data: any): boolean => {
|
||||
switch (filter.type) {
|
||||
case HeaderType.String:
|
||||
return filterString(filter.value, data);
|
||||
case HeaderType.Number:
|
||||
return filterNumber(filter.value, data);
|
||||
case HeaderType.Select:
|
||||
return filterSelect(filter.value, data);
|
||||
case HeaderType.Multiple:
|
||||
return filterMultiple(filter.value, data);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
6
_src/components/Table/Table.module.css
Normal file
6
_src/components/Table/Table.module.css
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.table th,
|
||||
.table td {
|
||||
padding: 0.25rem 1rem;
|
||||
border: 1px solid white;
|
||||
text-align: center;
|
||||
}
|
||||
197
_src/components/Table/Table.tsx
Normal file
197
_src/components/Table/Table.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import usePagination, { Offset } from 'src/hooks/usePagination';
|
||||
import { SelectFilter, NumberFilter, resolveFilterByType } from './Filters';
|
||||
import type { DataItem, Header, Value, Filter } from './types';
|
||||
import { HeaderType } from './types';
|
||||
import styles from './Table.module.css';
|
||||
|
||||
interface Props {
|
||||
data: DataItem[];
|
||||
headers: Header[];
|
||||
}
|
||||
|
||||
export default function Table({ data, headers }: Props): JSX.Element {
|
||||
const [filters, setFilters] = useState<Record<string, Filter>>({});
|
||||
const filtersId = useRef(crypto.randomUUID());
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
return data.filter((item) => {
|
||||
return Object.entries(filters).every(([key, filter]) =>
|
||||
resolveFilterByType(filter, item[key]),
|
||||
);
|
||||
});
|
||||
}, [data, filters]);
|
||||
|
||||
const { items, changeOffset, getPaginationRange } = usePagination<DataItem>({
|
||||
items: filteredItems,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const handleUpdateFilters = (
|
||||
name: string,
|
||||
type: HeaderType,
|
||||
value: Value,
|
||||
): void => {
|
||||
setFilters((prev) => {
|
||||
if (value === null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete prev[name];
|
||||
return { ...prev };
|
||||
}
|
||||
|
||||
return { ...prev, [name]: { value, type } };
|
||||
});
|
||||
};
|
||||
|
||||
function formatCell(data: DataItem, header: Header): JSX.Element {
|
||||
// This formatting is only used because the source is trusted (private markdown files manage only by me)
|
||||
// and because Astro don't allow me to pass JSX from an Astro file to a TSX file,
|
||||
// so I have to pass the formatted row as a string.
|
||||
// DON'T use this method on a public API
|
||||
if (header.hasCustomCell && header.formatter) {
|
||||
return (
|
||||
<div dangerouslySetInnerHTML={{ __html: header.formatter(data) }} />
|
||||
);
|
||||
}
|
||||
|
||||
if (header.type === HeaderType.Multiple) {
|
||||
return (
|
||||
<ul className="text-start">
|
||||
{data[header.key].map((item: JSX.Element, idx: number) => (
|
||||
<li key={idx}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
return data[header.key];
|
||||
}
|
||||
|
||||
function formatFilter(header: Header): JSX.Element {
|
||||
const baseProps = {
|
||||
key: header.key + filtersId.current,
|
||||
keyData: header.key,
|
||||
value: filters[header.key]?.value,
|
||||
onChange: (value: Value) => {
|
||||
handleUpdateFilters(header.key, header.type, value);
|
||||
},
|
||||
};
|
||||
|
||||
switch (header.type) {
|
||||
case HeaderType.String:
|
||||
return (
|
||||
<input
|
||||
onChange={(e) => {
|
||||
baseProps.onChange(e.target.value);
|
||||
}}
|
||||
key={baseProps.key}
|
||||
/>
|
||||
);
|
||||
case HeaderType.Number:
|
||||
return (
|
||||
<NumberFilter
|
||||
{...baseProps}
|
||||
onChange={(value: [string, number | null]) => {
|
||||
handleUpdateFilters(header.key, header.type, value as Value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case HeaderType.Select:
|
||||
return <SelectFilter data={data} {...baseProps} />;
|
||||
case HeaderType.Multiple:
|
||||
return <SelectFilter {...baseProps} isMultiple data={data} />;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="hstack">
|
||||
<button
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
setFilters({});
|
||||
filtersId.current = crypto.randomUUID();
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="mt-1 overflow-scroll">
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
{headers.map((item, idx) => (
|
||||
<th key={idx}>
|
||||
<div className="vstack">
|
||||
{item.header}
|
||||
{formatFilter(item)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{items.map((item, idx) => (
|
||||
<tr key={idx}>
|
||||
{headers.map((header, hidx) => (
|
||||
<td key={hidx}>{formatCell(item, header)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section className="mt-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
changeOffset(Offset.First);
|
||||
}}
|
||||
>
|
||||
First
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
changeOffset(Offset.Prev);
|
||||
}}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
|
||||
{getPaginationRange().map((item) => (
|
||||
<button
|
||||
className={item.current ? 'btn-primary' : ''}
|
||||
key={item.page}
|
||||
onClick={() => {
|
||||
changeOffset(Offset.To, item.page);
|
||||
}}
|
||||
>
|
||||
{item.page}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
changeOffset(Offset.Next);
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
changeOffset(Offset.Last);
|
||||
}}
|
||||
>
|
||||
Last
|
||||
</button>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
_src/components/Table/index.ts
Normal file
1
_src/components/Table/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './Table';
|
||||
24
_src/components/Table/types.ts
Normal file
24
_src/components/Table/types.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export type DataItem = Record<string, any>;
|
||||
|
||||
export type Value = string | string[] | number | number[] | null;
|
||||
|
||||
export enum HeaderType {
|
||||
Index,
|
||||
String,
|
||||
Number,
|
||||
Select,
|
||||
Multiple,
|
||||
}
|
||||
|
||||
export interface Header {
|
||||
key: string;
|
||||
header: string;
|
||||
type: HeaderType;
|
||||
hasCustomCell?: boolean;
|
||||
formatter?: (data: any) => string;
|
||||
}
|
||||
|
||||
export interface Filter {
|
||||
type: HeaderType;
|
||||
value: Value;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue