From 72477a939858758e2e84f7a8cd071f0c666aa98d Mon Sep 17 00:00:00 2001 From: Arthri <41360489+Arthri@users.noreply.github.com> Date: Wed, 13 Dec 2023 03:26:03 +0800 Subject: [PATCH 01/17] Refactor out Table.Sortable & RecordsTable (#23102) * Add sortable table * Add IndexedTable * refactor MedicalRecords to use IndexedTable * Reorganize children * Finish conversion * rename SearchFlex to Toolbar * import IndexedTable.scss * Deduplicate CSS * Make margins look in toolbar better * Rename IndexedTable to RecordsTable * Remove redundant classes * Unselect sort by clicking on icon rather than double click * Fix premature scrollbar * Revert field searching * Implement number sort * Implement compare for undefined, null, bigint, and boolean * simplify sorting * Remove redundant class * Add documentation * Fix med records * copy paste error * consistency fixes * Allow null as prop.sortId * document all * Remove redundant compute * Remove redundant usings * restore missing commas * fix missing hashtag in account number * Document column order * Build and update tgui * Prettier format * Reorganize props * [ci skip] --------- Co-authored-by: Arthri <41360489+a@users.noreply.github.com> Co-authored-by: tgui <41898282+github-actions[bot]@users.noreply.github.com> --- tgui/docs/component-reference.md | 116 ++++++++ tgui/packages/tgui/components/Table.js | 157 ++++++++++- .../tgui/interfaces/AccountsUplinkTerminal.js | 221 ++++++--------- .../tgui/interfaces/MedicalRecords.js | 255 +++++------------- .../tgui/interfaces/SecurityRecords.js | 186 ++++--------- .../tgui/interfaces/common/RecordsTable.js | 55 ++++ tgui/packages/tgui/public/tgui.bundle.css | 2 +- tgui/packages/tgui/public/tgui.bundle.js | 6 +- .../interfaces/AccountsUplinkTerminal.scss | 18 -- .../styles/interfaces/MedicalRecords.scss | 18 -- .../styles/interfaces/SecurityRecords.scss | 18 -- .../interfaces/common/RecordsTable.scss | 29 ++ tgui/packages/tgui/styles/main.scss | 1 + 13 files changed, 559 insertions(+), 523 deletions(-) create mode 100644 tgui/packages/tgui/interfaces/common/RecordsTable.js create mode 100644 tgui/packages/tgui/styles/interfaces/common/RecordsTable.scss diff --git a/tgui/docs/component-reference.md b/tgui/docs/component-reference.md index c892cb628be9..4070b5d46c36 100644 --- a/tgui/docs/component-reference.md +++ b/tgui/docs/component-reference.md @@ -44,12 +44,15 @@ Make sure to add new items to this list if you document new components. - [`Table`](#table) - [`Table.Row`](#tablerow) - [`Table.Cell`](#tablecell) + - [`Table.Sortable`](#tablesortable) - [`Tabs`](#tabs) - [`Tabs.Tab`](#tabstab) - [`Tooltip`](#tooltip) - [`tgui/layouts`](#tguilayouts) - [`Window`](#window) - [`Window.Content`](#windowcontent) +- [`tgui/interfaces/common`](#tguiinterfacescommon) + - [`RecordsTable`](#recordstable) ## General Concepts @@ -844,6 +847,103 @@ A straight forward mapping to `` element. - `collapsing: boolean` - Collapses table cell to the smallest possible size, and stops any text inside from wrapping. +### `Table.Sortable` + +A managed sortable table. + +**Props:** + +- See inherited props: [Table](#table) +- `columns: Column[]` - A list of data fields to be + used. The order in which columns are specified is the order in which they + appear in the UI. +- `columnDefaults: UnnamedColumn` - Default values for all columns. + `columnDefaults.datum.children` can be used to specify the default children, + but will get overriden by children defined at each column. + `columnDefaults.datum.props` and `columnDefaults.header.props` can be used to + specify additional props for the datum and header respectively. +- `data: object[]` - The data to put into the table. +- `datumID: object => object` - A function which takes in a datum and returns + an id. +- `filter: object[] => object[]` - A function applied to the data before + sorting the table. The `createSearch` function can be applied here. +- `headerRowProps: object` - Props to apply to the header row. +- `datumRowProps: object | object => object` - Props to apply to the data + rows. If a function is specified instead, the data associated with that row + is passed into it. +- `...rest` - The rest of the props are applied on the table. +- `children: Component[]` - Not supported. Do not use. + +The column types is defined as such: + +```ts +interface UnnamedColumn { + // ? signifies optional + datum?: { + // Pass the children directly or pass a function which takes the value of + // the cell and returns the props. Setting this property overrides the + // default children. + children?: React.ReactNode | ((value: object) => React.ReactNode); + // Pass the additional props directly or pass a function which takes the + // value of the cell and returns the additional props. + props?: CellProps | ((value: object) => CellProps); + }; + header?: { + // Additional props for the header cell. + props?: HeaderProps; + }; +} + +// Inherits properties from UnnamedColumn +type Column = UnnamedColumn & { + id: string; + name: string; +}; +``` + +The following is an example of how to use `Table.Sortable`. + +```jsx +const data = [ + { 'account_number': 6224001, 'name': 'Command Account', 'suspended': 'Active', 'money': 7000, }, + { 'account_number': 3099002, 'name': 'Security Account', 'suspended': 'Active', 'money': 14000, }, + { 'account_number': 8652003, 'name': 'Science Account', 'suspended': 'Active', 'money': 7000, }, + { 'account_number': 8422004, 'name': 'Service Account', 'suspended': 'Active', 'money': 7000, }, + { 'account_number': 9853005, 'name': 'Supply Account', 'suspended': 'Active', 'money': 7000, }, + { 'account_number': 1866006, 'name': 'Engineering Account', 'suspended': 'Active', 'money': 7250, }, + { 'account_number': 3811007, 'name': 'Medical Account', 'suspended': 'Active', 'money': 7000, }, + { 'account_number': 3945008, 'name': 'Assistant Account', 'suspended': 'Active', 'money': 4500, }, +] + + datum.account_number} + + datumRowProps={(datum) => ({ + className: `AccountsUplinkTerminal__listRow--${datum.suspended}`, + })} + datumCellChildren={{ + name: (value) => <> {value}, + }} +/> +``` + +In the above example, the `columns` prop defines the fields in the data that +are used. `columns.id` is the key or the name of the property on the data, +while `columns.name` is what the user sees on the UI. `datumID` selects which +field in the data to use as the key for render caching. `datumID` can return +anything, but it must be unique. + +`datumRowProps` applies a class to a row if the account associated with that +row is suspended, and `datumCellChildren` prepends a wallet to the contents +of the `name` column. + ### `Tabs` Tabs make it easy to explore and switch between different views. @@ -968,3 +1068,19 @@ Can be scrollable. - `className: string` - Applies a CSS class to the element. - `scrollable: boolean` - Shows or hides the scrollbar. - `children: any` - Main content of your window. + +## `tgui/interfaces/common` + +## `RecordsTable` + +An extension to [`Table.Sortable`](#tablesortable) which provides a search +function and slots for buttons. + +**Props:** + +- See inherited props: [`Table.Sortable`](#tablesortable) +- `leftButtons: Component` - Optional buttons to add left of the search box. +- `rightButtons: Component` - Optional buttons to add right of the search box. +- `searchPlaceholder: string` - The default text in the search box. + +Use a ``(or `<>`) to specify multiple buttons. diff --git a/tgui/packages/tgui/components/Table.js b/tgui/packages/tgui/components/Table.js index 681a795ed42c..14228caa20f5 100644 --- a/tgui/packages/tgui/components/Table.js +++ b/tgui/packages/tgui/components/Table.js @@ -1,5 +1,8 @@ import { classes, pureComponentHooks } from 'common/react'; -import { Box, computeBoxClassName, computeBoxProps } from './Box'; +import { computeBoxClassName, computeBoxProps } from './Box'; +import { Component } from 'inferno'; +import { Button } from './Button'; +import { Icon } from './Icon'; export const Table = (props) => { const { className, collapsing, children, ...rest } = props; @@ -53,7 +56,159 @@ export const TableCell = (props) => { ); }; +const resolveFunctionalProp = (props, ...data) => + props ? (props instanceof Function ? props(...data) : props) : undefined; +class HoverableIcon extends Component { + constructor() { + super(); + this.state = { + hovering: false, + }; + this.handleMouseOver = (e) => { + this.setState({ hovering: true }); + }; + this.handleMouseOut = (e) => { + this.setState({ hovering: false }); + }; + } + + render() { + const { hoverIcon, name, ...rest } = this.props; + const { hovering } = this.state; + return ( + + ); + } +} + +class SortableTable extends Component { + constructor(props) { + super(); + this.state = { + // Allow null + sortId: props.sortId === undefined ? props.columns[0].id : props.sortId, + sortOrder: props.sortOrder ?? 1, + }; + } + + render() { + const { + className, + columnDefaults, + columns, + data, + datumID, + filter, + headerRowProps, + datumRowProps, + ...rest + } = this.props; + const { sortId, sortOrder } = this.state; + + const columnHeaders = columns.map( + ({ id, name, header: { props } = {} }) => { + const { + header: { props: defaultProps }, + } = columnDefaults; + return ( + + + + ); + } + ); + const dataRows = (filter ? filter(data) : data) + .sort((a, b) => { + if (sortId) { + const i = sortOrder ? 1 : -1; + return a[sortId].toString().localeCompare(b[sortId].toString()) * i; + } else { + return 0; + } + }) + .map((datum) => { + let cells = columns.map( + ({ id, name, datum: { props, children } = {} }) => { + const { + datum: { children: defaultChildren, props: defaultProps }, + } = columnDefaults; + return ( + + {resolveFunctionalProp(children, datum[id]) ?? + resolveFunctionalProp(defaultChildren, datum[id]) ?? + datum[id]} + + ); + } + ); + return ( + + {cells} + + ); + }); + return ( + + + {columnHeaders} + + {dataRows} +
+ ); + } +} + TableCell.defaultHooks = pureComponentHooks; Table.Row = TableRow; Table.Cell = TableCell; +Table.Sortable = SortableTable; diff --git a/tgui/packages/tgui/interfaces/AccountsUplinkTerminal.js b/tgui/packages/tgui/interfaces/AccountsUplinkTerminal.js index ecd2d7a397c9..b509baced50f 100644 --- a/tgui/packages/tgui/interfaces/AccountsUplinkTerminal.js +++ b/tgui/packages/tgui/interfaces/AccountsUplinkTerminal.js @@ -1,9 +1,7 @@ -import { createSearch } from 'common/string'; import { Fragment } from 'inferno'; import { useBackend, useLocalState } from '../backend'; import { Button, - Flex, Icon, Input, LabeledList, @@ -11,11 +9,10 @@ import { Table, Tabs, } from '../components'; -import { FlexItem } from '../components/Flex'; -import { TableCell } from '../components/Table'; import { Window } from '../layouts'; import { LoginInfo } from './common/LoginInfo'; import { LoginScreen } from './common/LoginScreen'; +import { RecordsTable } from './common/RecordsTable'; export const AccountsUplinkTerminal = (properties, context) => { const { act, data } = useBackend(context); @@ -42,7 +39,7 @@ export const AccountsUplinkTerminal = (properties, context) => { return ( - + {body} @@ -81,159 +78,95 @@ const AccountsUplinkTerminalContent = (props, context) => { } }; -const AccountsRecordList = (properties, context) => { +const AccountsRecordList = (props, context) => { const { act, data } = useBackend(context); const { accounts } = data; - const [searchText, setSearchText] = useLocalState(context, 'searchText', ''); - const [sortId, _setSortId] = useLocalState(context, 'sortId', 'owner_name'); - const [sortOrder, _setSortOrder] = useLocalState(context, 'sortOrder', true); return ( - - - -
- - - Account Holder - Account Number - Account Status - Account Balance - - {accounts - .filter( - createSearch(searchText, (account) => { - return ( - account.owner_name + - '|' + - account.account_number + - '|' + - account.suspended + - '|' + - account.money - ); - }) - ) - .sort((a, b) => { - const i = sortOrder ? 1 : -1; - return a[sortId].localeCompare(b[sortId]) * i; - }) - .map((account) => ( - - act('view_account_detail', { - account_num: account.account_number, - }) - } - > - - {account.owner_name} - - #{account.account_number} - {account.suspended} - {account.money} - - ))} -
-
-
-
+ ( + <> + {value} + + ), + }, + }, + { + id: 'account_number', + name: 'Account Number', + datum: { children: (value) => <>#{value} }, + }, + { id: 'suspended', name: 'Account Status' }, + { id: 'money', name: 'Account Balance' }, + ]} + data={accounts} + datumID={(datum) => datum.account_number} + leftButtons={ + - - ); -}; - -const AccountsActions = (properties, context) => { - const { act, data } = useBackend(context); - const { is_printing } = data; - const [searchText, setSearchText] = useLocalState(context, 'searchText', ''); - return ( - - + ( + <> + {value} + + ), + }, + }, + { + id: 'account_number', + name: 'Account Number', + datum: { children: (value) => <>#{value} }, + }, + { id: 'suspended', name: 'Account Status' }, + { id: 'money', name: 'Account Balance' }, + ]} + data={department_accounts} + datumID={(datum) => datum.account_number} + leftButtons={ - - ); -}; - -const SortButton2 = (properties, context) => { - const [sortId2, setSortId2] = useLocalState(context, 'sortId2', 'name'); - const [sortOrder2, setSortOrder2] = useLocalState( - context, - 'sortOrder2', - true - ); - const { id, children } = properties; - return ( - - - - ); -}; - const MedicalRecordsNavigation = (_properties, context) => { const { act, data } = useBackend(context); const { screen, general } = data; diff --git a/tgui/packages/tgui/interfaces/SecurityRecords.js b/tgui/packages/tgui/interfaces/SecurityRecords.js index b908f6d7ff36..e49a3ff98706 100644 --- a/tgui/packages/tgui/interfaces/SecurityRecords.js +++ b/tgui/packages/tgui/interfaces/SecurityRecords.js @@ -1,23 +1,13 @@ -import { createSearch, decodeHtmlEntities } from 'common/string'; +import { decodeHtmlEntities } from 'common/string'; import { Fragment } from 'inferno'; -import { useBackend, useLocalState } from '../backend'; -import { - Box, - Button, - Flex, - Icon, - Input, - LabeledList, - Section, - Table, - Tabs, -} from '../components'; -import { FlexItem } from '../components/Flex'; +import { useBackend } from '../backend'; +import { Box, Button, Icon, LabeledList, Section, Tabs } from '../components'; import { Window } from '../layouts'; import { ComplexModal, modalOpen } from './common/ComplexModal'; import { LoginInfo } from './common/LoginInfo'; import { LoginScreen } from './common/LoginScreen'; import { TemporaryNotice } from './common/TemporaryNotice'; +import { RecordsTable } from './common/RecordsTable'; const statusStyles = { '*Execute*': 'execute', @@ -95,128 +85,56 @@ const SecurityRecordsNavigation = (properties, context) => { ); }; -const SecurityRecordsPageList = (properties, context) => { +const SecurityRecordsPageList = (props, context) => { const { act, data } = useBackend(context); - const { records } = data; - const [searchText, setSearchText] = useLocalState(context, 'searchText', ''); - const [sortId, _setSortId] = useLocalState(context, 'sortId', 'name'); - const [sortOrder, _setSortOrder] = useLocalState(context, 'sortOrder', true); + const { isPrinting, records } = data; return ( - - -
- - - Name - ID - Assignment - Fingerprint - Criminal Status - - {records - .filter( - createSearch(searchText, (record) => { - return ( - record.name + - '|' + - record.id + - '|' + - record.rank + - '|' + - record.fingerprint + - '|' + - record.status - ); - }) - ) - .sort((a, b) => { - const i = sortOrder ? 1 : -1; - return a[sortId].localeCompare(b[sortId]) * i; - }) - .map((record) => ( - - act('view', { - uid_gen: record.uid_gen, - uid_sec: record.uid_sec, - }) - } - > - - {record.name} - - {record.id} - {record.rank} - {record.fingerprint} - {record.status} - - ))} -
-
-
- ); -}; - -const SortButton = (properties, context) => { - const [sortId, setSortId] = useLocalState(context, 'sortId', 'name'); - const [sortOrder, setSortOrder] = useLocalState(context, 'sortOrder', true); - const { id, children } = properties; - return ( - - - - ); -}; - -const SecurityRecordsActions = (properties, context) => { - const { act, data } = useBackend(context); - const { isPrinting } = data; - const [searchText, setSearchText] = useLocalState(context, 'searchText', ''); - return ( - - -