diff --git a/CLAUDE-TS-conversion-guide.md b/CLAUDE-TS-conversion-guide.md new file mode 100644 index 0000000000..e101a8cb5d --- /dev/null +++ b/CLAUDE-TS-conversion-guide.md @@ -0,0 +1,904 @@ +# TypeScript Conversion & Type Safety Guide + +This guide documents best practices, lessons learned, and guidelines for converting JavaScript to TypeScript and improving type safety in the VEuPathDB web monorepo. + +## Table of Contents + +1. [JavaScript to TypeScript Conversion](#javascript-to-typescript-conversion) +2. [Reducing Unsafe Type Assertions](#reducing-unsafe-type-assertions) +3. [Common Patterns in This Codebase](#common-patterns-in-this-codebase) +4. [Specific Component Guidelines](#specific-component-guidelines) +5. [Testing and Verification](#testing-and-verification) + +--- + +## JavaScript to TypeScript Conversion + +### General Principles + +**Goal:** Convert JavaScript files to TypeScript while maintaining 100% functional compatibility and zero runtime behavior changes. + +**Key Rules:** + +- Keep class-based components as class-based (don't convert to functional) +- Keep functional components as functional (don't convert to class-based) +- Look for existing TypeScript types in the codebase before creating new ones +- Remove PropTypes in favor of TypeScript interfaces +- Commit and push regularly to avoid losing work + +### Conversion Process + +#### 1. Preparation + +```bash +# Find all JavaScript files to convert +find packages/libs/PACKAGE/src -name "*.js" -o -name "*.jsx" + +# Check for existing types +grep -r "interface\|type" packages/libs/PACKAGE/src --include="*.ts" --include="*.d.ts" +``` + +#### 2. File-by-File Conversion + +**For each JavaScript file:** + +1. **Read the file** to understand its structure +2. **Identify dependencies** and their types +3. **Rename the file** using `git mv` to preserve history: + + ```bash + # For JSX files + git mv Component.jsx Component.tsx + + # For plain JavaScript files + git mv utility.js utility.ts + ``` + +4. **Update the file** with proper TypeScript types: + - Import types from existing TypeScript files where available + - Create interfaces for component props and state + - Add type annotations to function parameters and return values + - Remove PropTypes imports and definitions +5. **Verify compilation** (see Testing and Verification section below) + +**Important:** Always use `git mv` instead of renaming files manually. This preserves git history and makes it easier to track changes across the rename. + +#### 3. Handling Common Patterns + +**React Components:** + +```typescript +// Class component +interface MyComponentProps { + value: string; + onChange: (value: string) => void; +} + +interface MyComponentState { + isExpanded: boolean; +} + +class MyComponent extends React.Component { + constructor(props: MyComponentProps) { + super(props); + this.state = { isExpanded: false }; + } +} +``` + +**Functional Components:** + +```typescript +interface MyFunctionProps { + value: string; + onChange: (value: string) => void; +} + +const MyFunction: React.FC = ({ value, onChange }) => { + // implementation +}; +``` + +**Generic Components:** + +```typescript +// For components that work with different data types +interface TableProps { + rows: Row[]; + columns: Column[]; +} + +class Table extends React.Component> { + // implementation +} +``` + +#### 4. PropTypes Migration + +**Before (JavaScript):** + +```javascript +import PropTypes from 'prop-types'; + +MyComponent.propTypes = { + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + optional: PropTypes.number, +}; +``` + +**After (TypeScript):** + +```typescript +interface MyComponentProps { + value: string; + onChange: (value: string) => void; + optional?: number; +} +``` + +### Common Type Imports + +**WDK Model Types:** + +```typescript +import { + RecordClass, + RecordInstance, + Question, + AttributeField, + AttributeValue, + LinkAttributeValue, + TableField, +} from '../Utils/WdkModel'; +``` + +**Mesa Types:** + +```typescript +import type { + MesaColumn, + MesaStateProps, +} from '@veupathdb/coreui/lib/components/Mesa/types'; +``` + +**Category/Ontology Types:** + +```typescript +import { + CategoryTreeNode, + IndividualNode, + CategoryNode, + isIndividual, +} from '../Utils/CategoryUtils'; +``` + +--- + +## Reducing Unsafe Type Assertions + +### Philosophy + +Treat `as` as a last resort. The goal is to have TypeScript infer correct types through proper type annotations, not to force types through assertions. + +### Decision Tree for Each `as Something` + +``` +1. Can I fix types at the source? + ├─ Yes → Add/improve type annotations, generics, discriminated unions + └─ No → Continue to 2 + +2. Is this from untyped/poorly typed code (3rd-party, JSON, DOM)? + ├─ Yes → Use runtime checks + type guards + └─ No → Continue to 3 + +3. Is this an allowed case? + ├─ Yes (unknown/any boundary, DOM, 3rd-party) → Keep but minimize scope + └─ No → Must refactor to remove +``` + +### Fix Types at the Source + +**❌ Bad: Using `as` to work around poor types** + +```typescript +const result = myFunc() as MyType; +``` + +**✅ Good: Add proper return type to function** + +```typescript +function myFunc(): MyType { + // implementation +} +const result = myFunc(); // Type inferred correctly +``` + +**❌ Bad: Casting array initialization** + +```typescript +const items = [] as MyType[]; +``` + +**✅ Good: Use generic type parameter** + +```typescript +// Option 1: Type annotation +const items: MyType[] = []; + +// Option 2: Generic reduce +array.reduce((acc, item) => { + // ... +}, []); +``` + +**❌ Bad: Asserting object literals** + +```typescript +const config = { + name: 'test', + value: 42, +} as Config; +``` + +**✅ Good: Use `satisfies` for validation** + +```typescript +const config = { + name: 'test', + value: 42, +} satisfies Config; +``` + +### Use Runtime Checks and Type Guards + +**❌ Bad: Blind assertion** + +```typescript +const value = data.value as string; +``` + +**✅ Good: Runtime check first** + +```typescript +const value = typeof data.value === 'string' ? data.value : ''; +``` + +**❌ Bad: Assuming property exists** + +```typescript +const text = (obj as any).displayText; +``` + +**✅ Good: Type guard with proper checks** + +```typescript +const text = + typeof obj === 'object' && obj !== null && 'displayText' in obj + ? (obj as { displayText: string }).displayText + : ''; +``` + +**✅ Better: Use proper type from WdkModel** + +```typescript +import { LinkAttributeValue } from '../Utils/WdkModel'; + +const text = + typeof obj === 'object' && obj !== null && 'displayText' in obj + ? (obj as LinkAttributeValue).displayText + : ''; +``` + +### Allowed `as` Cases + +**1. Casting from `unknown` or `any` at boundaries (after runtime checks)** + +```typescript +function parseData(json: unknown): MyData { + // Runtime validation + if (typeof json !== 'object' || json === null) { + throw new Error('Invalid data'); + } + // Now safe to assert + return json as MyData; +} +``` + +**2. DOM interactions with specific element types** + +```typescript +const button = document.getElementById('myButton') as HTMLButtonElement; +// Acceptable if you know the element exists and is a button +``` + +**3. Third-party libraries without accurate typings** + +```typescript +// Wrap in a small helper rather than scattering assertions +function safeLibCall(input: string): MyType { + return poorlyTypedLib.method(input) as MyType; +} +``` + +**4. Non-null assertions after explicit checks** + +```typescript +if (!map.has(key)) { + map.set(key, []); +} +const array = map.get(key)!; // Safe - we just set it +``` + +### Common Patterns to Improve + +**Pattern: Discriminated Unions** + +❌ Bad: + +```typescript +type Filter = MemberFilter | RangeFilter | MultiFilter; + +function process(filter: Filter) { + if ((filter.value as MultiFilterValue).filters) { + // use as MultiFilter + } +} +``` + +✅ Good: + +```typescript +type Filter = + | { type: 'member'; value: MemberValue } + | { type: 'range'; value: RangeValue } + | { type: 'multi'; value: MultiFilterValue }; + +function process(filter: Filter) { + if (filter.type === 'multi') { + // TypeScript knows filter.value is MultiFilterValue + filter.value.filters.forEach(/* ... */); + } +} +``` + +**Pattern: Generic Reduce** + +❌ Bad: + +```typescript +const result = items.reduce((acc, item) => { + return { ...acc, [item.id]: item }; +}, {} as Record); +``` + +✅ Good: + +```typescript +const result = items.reduce>((acc, item) => { + return { ...acc, [item.id]: item }; +}, {}); +``` + +--- + +## Common Patterns in This Codebase + +### Mesa Component Pattern + +Mesa is now fully TypeScript with generic types. Use them! + +**✅ Correct Mesa Usage:** + +```typescript +import { Mesa } from '@veupathdb/coreui/lib/components/Mesa'; +import type { + MesaColumn, + MesaStateProps, +} from '@veupathdb/coreui/lib/components/Mesa/types'; + +// Define your row type +type MyRow = { + id: string; + name: string; + value: number; +}; + +// Create properly typed state +const tableState: MesaStateProps = { + rows: myRows, + columns: myColumns as MesaColumn[], + eventHandlers: { + onSort: (column: MesaColumn, direction: string) => { + // TypeScript knows column.key is string + }, + }, + uiState: { + sort: { + columnKey: sortColumn, + direction: 'asc' as 'asc' | 'desc', + }, + }, + options: { + toolbar: true, + }, +}; + +// Use without assertion +; +``` + +### WDK Model Pattern + +**Attribute Values:** + +```typescript +import { AttributeValue, LinkAttributeValue } from '../Utils/WdkModel'; + +// AttributeValue is: string | LinkAttributeValue | null + +function renderValue(value: AttributeValue) { + if (typeof value === 'object' && value !== null && 'displayText' in value) { + // TypeScript knows this is LinkAttributeValue + return {value.displayText}; + } + // TypeScript knows this is string | null + return value; +} +``` + +### Category Tree Pattern + +**Use type guards:** + +```typescript +import { + CategoryTreeNode, + IndividualNode, + isIndividual, +} from '../Utils/CategoryUtils'; + +function processNode(node: CategoryTreeNode) { + if (isIndividual(node)) { + // TypeScript knows node is IndividualNode + const ref = node.wdkReference; + // ... + } +} +``` + +### HOC Wrapping Pattern + +Higher-order components often need assertions. This is acceptable: + +```typescript +import { wrappable, pure } from '../Utils/ComponentUtils'; + +export default wrappable(pure(MyComponent as any)); +// Acceptable - HOC typing is complex and architectural +``` + +### Props Spreading Pattern + +When spreading props to components with different interfaces: + +```typescript +// If the interfaces genuinely differ, assertion may be needed + + +// But prefer explicit prop passing when possible + +``` + +--- + +## Specific Component Guidelines + +### Converting Mesa Components + +Mesa components use generics extensively. When converting: + +1. **Identify the Row type** - what data is being displayed? +2. **Identify the Key type** - usually `string` for column keys +3. **Use generic parameters** everywhere: + + ```typescript + interface MyComponentProps extends MesaStateProps { + extraProp: string; + } + + class MyComponent extends Component> { + // ... + } + ``` + +### Converting Record Components + +Record components work with WDK model types: + +```typescript +import { + RecordClass, + RecordInstance, + AttributeField, + TableField, +} from '../Utils/WdkModel'; + +interface RecordComponentProps { + record: RecordInstance; + recordClass: RecordClass; + attributes: AttributeField[]; + tables: TableField[]; +} +``` + +### Converting Form Components + +Form components often have complex event handlers: + +```typescript +interface FormProps { + value: FormData; + onChange: (value: FormData) => void; + onSubmit: (value: FormData) => Promise; +} + +class Form extends Component { + handleChange = (field: string) => (value: string) => { + this.props.onChange({ + ...this.props.value, + [field]: value, + }); + }; + + handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await this.props.onSubmit(this.props.value); + }; +} +``` + +--- + +## Testing and Verification + +### Before Committing + +**1. Install Dependencies (first time only):** + +```bash +# Download and install all dependencies (can take a few minutes) +yarn +``` + +**2. Build and Verify TypeScript Compilation:** + +```bash +# Build wdk-client and its dependencies (cached if no changes) +yarn nx build-npm-modules @veupathdb/wdk-client + +# Or, if dependencies are already built, build just wdk-client: +yarn workspace @veupathdb/wdk-client build-npm-modules +``` + +Must complete successfully with no errors. + +**3. Check for Remaining Issues:** + +```bash +# Count 'as any' assertions +grep -r "as any" packages/libs/PACKAGE/src --include="*.ts" --include="*.tsx" | wc -l + +# Find files with most assertions +grep -r " as " packages/libs/PACKAGE/src --include="*.ts" --include="*.tsx" | \ + grep -v "as const" | cut -d: -f1 | sort | uniq -c | sort -rn | head -20 +``` + +**3. Runtime Testing:** + +- If possible, run the application and test affected features +- Check browser console for errors +- Verify no new warnings + +### Commit Strategy + +**Commit frequently with clear messages:** + +```bash +# Good commit messages +git commit -m "Convert Mesa Utils to TypeScript (6 files)" +git commit -m "Fix TypeScript errors in RecordTable (batch 1/3)" +git commit -m "Reduce 'as any' assertions in Answer components" +git commit -m "Improve type safety in Operations.tsx using generics" +``` + +**Batch related changes:** + +- Group by component/feature area +- Keep commits focused and atomic +- Push regularly to avoid losing work + +--- + +## Quick Reference + +### Type Import Locations + +| Type | Import Path | +| ------------------------------------------- | --------------------------------------------- | +| `RecordClass`, `Question`, `AttributeField` | `../Utils/WdkModel` | +| `MesaColumn`, `MesaStateProps` | `@veupathdb/coreui/lib/components/Mesa/types` | +| `CategoryTreeNode`, `isIndividual` | `../Utils/CategoryUtils` | +| `Step`, `StrategyDetails` | `../Utils/WdkUser` | + +### Common TypeScript Patterns + +```typescript +// Generic reduce +array.reduce((acc, item) => { ... }, initialValue) + +// Type guard +function isType(obj: unknown): obj is MyType { + return typeof obj === 'object' && obj !== null && 'property' in obj; +} + +// Discriminated union +type Result = + | { success: true; data: Data } + | { success: false; error: Error }; + +// Non-null assertion (after check) +if (map.has(key)) { + const value = map.get(key)!; +} + +// Satisfies (validation without widening) +const config = { ... } satisfies Config; + +// Optional chaining + nullish coalescing +const value = obj?.property?.nested ?? defaultValue; +``` + +--- + +## Trust Usage Patterns Over Type Definitions + +### Critical Principle + +**The implementation is the source of truth, not the types.** + +When reviewing JS→TS conversions, **always trust the original JavaScript usage patterns** over any existing TypeScript type definitions. Type definitions can be incorrect due to: + +1. **Human error** when types were first written +2. **Stale types** that weren't updated when the implementation changed +3. **Incorrect assumptions** during previous conversion attempts +4. **Copy-paste errors** from similar but different APIs + +### Verification Process + +When you encounter a type definition that seems questionable: + +1. **Find the original JavaScript implementation:** + + ```bash + # Check git history for the original .js/.jsx file + git log --all --follow --format="%H %s" -- "**/ComponentName.*" + git show ^:path/to/file.jsx + ``` + +2. **Search for actual usage in the codebase:** + + ```bash + # Find all consumers of the API + grep -r "functionName\|ComponentName" packages/ --include="*.js" --include="*.jsx" --include="*.ts" --include="*.tsx" + ``` + +3. **Compare implementations across time:** + ```bash + # View the diff between original JS and current TS + git diff HEAD -- path/to/file + ``` + +### Red Flags Indicating Type Problems + +Watch for these warning signs: + +- **Comments questioning types**: "NOTE: the typing of X seems wrong..." +- **Workarounds with type assertions**: `as unknown as SomeType` +- **Unused APIs**: If no one uses a function, the type might be wrong +- **Defensive code**: Logic working around "incorrect" types +- **Multiple consumers using assertions**: All callers need `as` to use it + +### Case Study: Mesa Component API Bugs + +During careful review of the Mesa TS conversion, we found **three significant API bugs** where types didn't match the original JavaScript: + +#### Bug 1: `getValue` Parameter Mismatch + +**Original JS (correct):** + +```javascript +getValue({ row, key }); // Pass the column key +``` + +**Incorrect TS type definition:** + +```typescript +getValue?: (props: { row: Row; index: number }) => Value; // Wrong: uses index +``` + +**How we found it:** + +- Checked `git log` for DataCell.jsx +- Found original always used `{ row, key }` +- No actual usage in codebase (unused API) + +**Fix:** + +```typescript +getValue?: (props: { row: Row; key: Key }) => Value; // Restored original API +``` + +#### Bug 2: `childRow` Breaking Change + +**Original JS (correct):** + +```javascript +childRow(rowIndex, row); // Two separate positional arguments +``` + +**Incorrect TS conversion:** + +```typescript +childRow({ rowIndex, rowData: row }); // Object argument - BREAKING! +``` + +**Evidence of breakage** - Found this workaround in AiExpressionSummary.tsx: + +```typescript +childRow: (badProps) => { + // NOTE: the typing of `ChildRowProps` seems wrong + // as it is called with two args, not one + const rowIndex = badProps as unknown as number; + const rowData = topics[rowIndex]; + // ... +``` + +**Fix:** + +```typescript +export type ChildRowFunc = (rowIndex: number, rowData: Row) => ReactElement; +childRow?: ChildRowFunc; +``` + +And in DataCell.tsx: + +```typescript +// Before: childRow({ rowIndex, rowData: row }) +// After: childRow(rowIndex, row) +``` + +#### Bug 3: `MesaAction` Swapped Parameters + +**Type definition claimed:** + +```typescript +handler?: (selection: Row[], columns, rows) => void; // "Full selection" +callback?: (row: Row, columns) => void; // "Single row" +``` + +**Runtime actually did:** + +```typescript +if (typeof handler === 'function') + selection.forEach((row) => handler(row, columns)); // Per-row iteration! + +if (typeof callback === 'function') callback(selection, columns, rows); // Full selection! +``` + +**They were backwards!** The type definition had them swapped. + +**Fix:** Corrected the signatures to match actual behavior. + +### Lessons Learned + +1. **Types can lie** - especially if they predate the implementation +2. **Check git blame** - see when types vs. implementation were introduced +3. **Look for workarounds** - they indicate type problems +4. **Unused code is suspicious** - if nobody uses it, type might be wrong +5. **Comments are clues** - developers often note when types seem off + +### Best Practices for Review + +When reviewing a TS conversion: + +✅ **DO:** + +- Check original .js/.jsx file in git history +- Search for all consumers of the API +- Look for type assertions at call sites +- Read comments questioning the types +- Verify types match actual runtime behavior + +❌ **DON'T:** + +- Assume existing .d.ts or types.ts files are correct +- Trust types without verifying against implementation +- Ignore comments about "weird" types +- Accept widespread `as any` usage as necessary + +## Lessons Learned from This Session + +### What Worked Well + +1. **Parallel agent execution** for converting multiple files simultaneously +2. **Systematic batching** of error fixes (10-15 errors per batch) +3. **Using generic type parameters** instead of `as` assertions +4. **Starting with utilities** before components (bottom-up approach) +5. **Regular commits and pushes** to preserve progress +6. **Trusting original JavaScript** over questionable TypeScript types +7. **Following the clues** (comments, workarounds, git history) + +### Common Pitfalls to Avoid + +1. **Don't skip reading existing type definitions** - they often already exist +2. **Don't use `as any` as a first resort** - understand why the type doesn't match +3. **Don't change class/functional component style** during conversion +4. **Don't batch too many changes** - keep commits focused +5. **Don't forget to delete old JavaScript files** after conversion + +### Key Insights + +- **Mesa's type system** is comprehensive - use it rather than fighting it +- **WDK Model types** are well-defined - import and use them +- **Runtime checks before assertions** make code safer and more maintainable +- **Generic type parameters** are almost always better than type assertions +- **HOC and component spreading** are the main remaining assertion challenges + +--- + +## Future Work Opportunities + +### High Priority + +- Convert remaining JavaScript in `web-common` packages +- Improve discriminated union patterns in filter components +- Add type guards for complex WDK model interactions + +### Medium Priority + +- Refactor HOC patterns for better typing +- Create utility type guards for common patterns +- Document component-specific type patterns + +### Low Priority + +- Review and improve remaining `as` assertions at boundaries +- Consider adding runtime validation libraries (e.g., zod, io-ts) +- Explore stricter TypeScript compiler options + +--- + +## Getting Help + +**When stuck on a type issue:** + +1. Check this guide for similar patterns +2. Search the codebase for existing solutions: `grep -r "similar pattern"` +3. Look at recently converted files for examples +4. Check TypeScript docs for the specific feature +5. Consider if the type issue reveals a real bug + +**Resources:** + +- [TypeScript Handbook](https://www.typescriptlang.org/docs/handbook/intro.html) +- [React TypeScript Cheatsheet](https://react-typescript-cheatsheet.netlify.app/) +- VEuPathDB web-monorepo existing TypeScript files (best examples) + +--- + +_Last updated: 2025-11-17_ +_Session: Mesa TS conversion review - Trust usage patterns over type definitions_ diff --git a/packages/libs/coreui/src/components/Mesa/Components/AnchoredTooltip.jsx b/packages/libs/coreui/src/components/Mesa/Components/AnchoredTooltip.tsx similarity index 64% rename from packages/libs/coreui/src/components/Mesa/Components/AnchoredTooltip.jsx rename to packages/libs/coreui/src/components/Mesa/Components/AnchoredTooltip.tsx index 91d58d1103..31f66bb55e 100644 --- a/packages/libs/coreui/src/components/Mesa/Components/AnchoredTooltip.jsx +++ b/packages/libs/coreui/src/components/Mesa/Components/AnchoredTooltip.tsx @@ -4,11 +4,27 @@ import { debounce } from 'lodash'; import MesaTooltip from './MesaTooltip'; import Events from '../Utils/Events'; -class AnchoredTooltip extends React.Component { - constructor(props) { +interface Position { + left?: number; + top?: number; +} + +interface AnchoredTooltipProps { + className?: string; + children?: React.ReactNode; + content: React.ReactNode; + [key: string]: any; +} + +class AnchoredTooltip extends React.Component { + private childWrapperRef: React.RefObject; + private listeners: { scroll?: string; resize?: string } = {}; + public updatePosition: (() => void) & { cancel: () => void }; + + constructor(props: AnchoredTooltipProps) { super(props); this.getPosition = this.getPosition.bind(this); - this.updatePosition = debounce(this.updatePosition.bind(this), 100); + this.updatePosition = debounce(this._updatePosition.bind(this), 100); this.componentDidMount = this.componentDidMount.bind(this); this.componentWillUnmount = this.componentWillUnmount.bind(this); this.childWrapperRef = React.createRef(); @@ -22,17 +38,17 @@ class AnchoredTooltip extends React.Component { } componentWillUnmount() { - Object.values(this.listeners).forEach((listenerId) => - Events.remove(listenerId) - ); + Object.values(this.listeners).forEach((listenerId) => { + if (listenerId) Events.remove(listenerId); + }); this.updatePosition.cancel(); } - updatePosition() { + _updatePosition() { this.forceUpdate(); } - getPosition() { + getPosition(): Position | undefined { const element = this.childWrapperRef.current; if (!element) return undefined; diff --git a/packages/libs/coreui/src/components/Mesa/Components/BodyLayer.jsx b/packages/libs/coreui/src/components/Mesa/Components/BodyLayer.tsx similarity index 72% rename from packages/libs/coreui/src/components/Mesa/Components/BodyLayer.jsx rename to packages/libs/coreui/src/components/Mesa/Components/BodyLayer.tsx index 1ebae1316f..53fc1e63db 100644 --- a/packages/libs/coreui/src/components/Mesa/Components/BodyLayer.jsx +++ b/packages/libs/coreui/src/components/Mesa/Components/BodyLayer.tsx @@ -1,8 +1,14 @@ import React from 'react'; import ReactDOM from 'react-dom'; -class BodyLayer extends React.Component { - constructor(props) { +interface BodyLayerProps { + [key: string]: any; +} + +class BodyLayer extends React.Component { + private el: HTMLDivElement; + + constructor(props: BodyLayerProps) { super(props); // XXX This will have to be guarded if we ever use server side rendering this.el = document.createElement('div'); diff --git a/packages/libs/coreui/src/components/Mesa/Components/Checkbox.jsx b/packages/libs/coreui/src/components/Mesa/Components/Checkbox.tsx similarity index 74% rename from packages/libs/coreui/src/components/Mesa/Components/Checkbox.jsx rename to packages/libs/coreui/src/components/Mesa/Components/Checkbox.tsx index ad38513157..53d3f32724 100644 --- a/packages/libs/coreui/src/components/Mesa/Components/Checkbox.jsx +++ b/packages/libs/coreui/src/components/Mesa/Components/Checkbox.tsx @@ -1,13 +1,23 @@ import React from 'react'; import IndeterminateCheckbox from '../../inputs/checkboxes/IndeterminateCheckbox'; -class Checkbox extends React.Component { - constructor(props) { +interface CheckboxProps { + checked: boolean; + onChange?: (checked: boolean) => void; + className?: string; + disabled?: boolean; + indeterminate?: boolean; +} + +class Checkbox extends React.Component { + constructor(props: CheckboxProps) { super(props); this.handleClick = this.handleClick.bind(this); } - handleClick(e) { + handleClick( + isCheckedOrEvent: boolean | React.ChangeEvent + ): void { let { checked, onChange } = this.props; if (typeof onChange === 'function') onChange(!!checked); } diff --git a/packages/libs/coreui/src/components/Mesa/Components/HelpTrigger.jsx b/packages/libs/coreui/src/components/Mesa/Components/HelpTrigger.tsx similarity index 70% rename from packages/libs/coreui/src/components/Mesa/Components/HelpTrigger.jsx rename to packages/libs/coreui/src/components/Mesa/Components/HelpTrigger.tsx index 07711e2974..51df8cb393 100644 --- a/packages/libs/coreui/src/components/Mesa/Components/HelpTrigger.jsx +++ b/packages/libs/coreui/src/components/Mesa/Components/HelpTrigger.tsx @@ -2,8 +2,14 @@ import React from 'react'; import Icon from './Icon'; import AnchoredTooltip from './AnchoredTooltip'; -class HelpTrigger extends React.Component { - constructor(props) { +interface HelpTriggerProps { + className?: string; + children?: React.ReactNode; + [key: string]: any; +} + +class HelpTrigger extends React.Component { + constructor(props: HelpTriggerProps) { super(props); } diff --git a/packages/libs/coreui/src/components/Mesa/Components/Icon.jsx b/packages/libs/coreui/src/components/Mesa/Components/Icon.tsx similarity index 58% rename from packages/libs/coreui/src/components/Mesa/Components/Icon.jsx rename to packages/libs/coreui/src/components/Mesa/Components/Icon.tsx index 25b18a37fc..4d7e073142 100644 --- a/packages/libs/coreui/src/components/Mesa/Components/Icon.jsx +++ b/packages/libs/coreui/src/components/Mesa/Components/Icon.tsx @@ -1,6 +1,13 @@ import React from 'react'; -class Icon extends React.PureComponent { +interface IconProps { + fa: string; + className?: string; + onClick?: (event: React.MouseEvent) => void; + style?: React.CSSProperties; +} + +class Icon extends React.PureComponent { render() { let { fa, className, onClick, style } = this.props; className = `icon fa fa-${fa} ${className || ''}`; diff --git a/packages/libs/coreui/src/components/Mesa/Components/MesaTooltip.tsx b/packages/libs/coreui/src/components/Mesa/Components/MesaTooltip.tsx index aa1b948790..db4aaf8448 100644 --- a/packages/libs/coreui/src/components/Mesa/Components/MesaTooltip.tsx +++ b/packages/libs/coreui/src/components/Mesa/Components/MesaTooltip.tsx @@ -16,7 +16,7 @@ interface MesaTooltipProps { corner?: string; position?: Position; style?: CSSProperties; - getPosition?: () => Position; + getPosition?: () => Position | undefined; renderHtml?: boolean; } diff --git a/packages/libs/coreui/src/components/Mesa/Components/ModalBoundary.jsx b/packages/libs/coreui/src/components/Mesa/Components/ModalBoundary.tsx similarity index 74% rename from packages/libs/coreui/src/components/Mesa/Components/ModalBoundary.jsx rename to packages/libs/coreui/src/components/Mesa/Components/ModalBoundary.tsx index 11995b97af..a80c9d97fc 100644 --- a/packages/libs/coreui/src/components/Mesa/Components/ModalBoundary.jsx +++ b/packages/libs/coreui/src/components/Mesa/Components/ModalBoundary.tsx @@ -1,12 +1,29 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { uid, makeClassifier } from '../Utils/Utils'; const modalBoundaryClass = makeClassifier('ModalBoundary'); -class ModalBoundary extends React.Component { - constructor(props) { +interface Modal { + _id?: string; + render: React.ComponentType; + [key: string]: any; +} + +interface ModalBoundaryProps { + children?: React.ReactNode; + style?: React.CSSProperties; +} + +interface ModalBoundaryState { + modals: Modal[]; +} + +class ModalBoundary extends React.Component< + ModalBoundaryProps, + ModalBoundaryState +> { + constructor(props: ModalBoundaryProps) { super(props); this.state = { modals: [] }; @@ -28,7 +45,7 @@ class ModalBoundary extends React.Component { ); } - addModal(modal) { + addModal(modal: Modal): string { let { modals } = this.state; modal._id = uid(); modals.push(modal); @@ -36,11 +53,11 @@ class ModalBoundary extends React.Component { return modal._id; } - triggerModalRefresh() { + triggerModalRefresh(): void { this.forceUpdate(); } - removeModal(id) { + removeModal(id: string): void { let { modals } = this.state; let index = modals.findIndex((modal) => modal._id === id); if (index < 0) return; @@ -55,7 +72,7 @@ class ModalBoundary extends React.Component { renderModalWrapper() { const { modals } = this.state; - const style = { + const style: React.CSSProperties = { top: 0, left: 0, width: '100vw', @@ -76,10 +93,17 @@ class ModalBoundary extends React.Component { render() { const { children, style } = this.props; const ModalWrapper = this.renderModalWrapper; - const fullStyle = Object.assign({}, style ? style : {}, { + const fullStyle: React.CSSProperties = Object.assign( + {}, + style ? style : {}, + { + position: 'relative', + } + ); + const zIndex = (z: number): React.CSSProperties => ({ position: 'relative', + zIndex: z, }); - const zIndex = (z) => ({ position: 'relative', zIndex: z }); return (
{ + constructor(props: OverScrollProps) { super(props); } render() { let { className, height } = this.props; className = 'OverScroll' + (className ? ' ' + className : ''); - height = typeof height === 'number' ? height + 'px' : 'none'; + const heightValue = typeof height === 'number' ? height + 'px' : 'none'; - const style = { - maxHeight: height, + const style: React.CSSProperties = { + maxHeight: heightValue, overflowY: 'auto', }; diff --git a/packages/libs/coreui/src/components/Mesa/Components/SelectBox.jsx b/packages/libs/coreui/src/components/Mesa/Components/SelectBox.tsx similarity index 62% rename from packages/libs/coreui/src/components/Mesa/Components/SelectBox.jsx rename to packages/libs/coreui/src/components/Mesa/Components/SelectBox.tsx index c189460010..d141f89591 100644 --- a/packages/libs/coreui/src/components/Mesa/Components/SelectBox.jsx +++ b/packages/libs/coreui/src/components/Mesa/Components/SelectBox.tsx @@ -1,26 +1,39 @@ import React from 'react'; -class SelectBox extends React.PureComponent { - constructor(props) { +interface SelectOption { + name: string; + value: string | number; +} + +interface SelectBoxProps { + name?: string; + className?: string; + selected?: string | number; + options?: (SelectOption | string | number)[]; + onChange?: (value: string) => void; +} + +class SelectBox extends React.PureComponent { + constructor(props: SelectBoxProps) { super(props); this.handleChange = this.handleChange.bind(this); } - handleChange(e) { + handleChange(e: React.ChangeEvent): void { const { onChange } = this.props; const value = e.target.value; if (onChange) onChange(value); } - getOptions() { + getOptions(): SelectOption[] { let { options } = this.props; if (!Array.isArray(options)) return []; - options = options.map((option) => { + const normalizedOptions: SelectOption[] = options.map((option) => { return typeof option === 'object' && 'name' in option && 'value' in option ? option : { name: option.toString(), value: option }; }); - return options; + return normalizedOptions; } render() { diff --git a/packages/libs/coreui/src/components/Mesa/Components/Toggle.jsx b/packages/libs/coreui/src/components/Mesa/Components/Toggle.tsx similarity index 52% rename from packages/libs/coreui/src/components/Mesa/Components/Toggle.jsx rename to packages/libs/coreui/src/components/Mesa/Components/Toggle.tsx index 81be3d1f00..99f59feffb 100644 --- a/packages/libs/coreui/src/components/Mesa/Components/Toggle.jsx +++ b/packages/libs/coreui/src/components/Mesa/Components/Toggle.tsx @@ -1,28 +1,37 @@ import React from 'react'; -import Icon from '../../../Components/Mesa/Components/Icon'; +import Icon from './Icon'; -class Toggle extends React.Component { - constructor(props) { +interface ToggleProps { + enabled: boolean; + onChange?: (enabled: boolean) => void; + className?: string; + disabled?: boolean; + style?: React.CSSProperties; +} + +class Toggle extends React.Component { + constructor(props: ToggleProps) { super(props); this.handleClick = this.handleClick.bind(this); } - handleClick(e) { - let { enabled, onChange } = this.props; + handleClick(e: React.MouseEvent): void { + const { enabled, onChange } = this.props; if (typeof onChange === 'function') onChange(!!enabled); } render() { - let { enabled, className, disabled, style } = this.props; + const { enabled, disabled, style } = this.props; + let className = this.props.className; className = 'Toggle' + (className ? ' ' + className : ''); className += ' ' + (enabled ? 'Toggle-On' : 'Toggle-Off'); className += disabled ? ' Toggle-Disabled' : ''; - let offStyle = { + const offStyle: React.CSSProperties = { fontSize: '1.2rem', color: '#989898', }; - let onStyle = Object.assign({}, offStyle, { + const onStyle: React.CSSProperties = Object.assign({}, offStyle, { color: '#198835', }); @@ -30,7 +39,7 @@ class Toggle extends React.Component { { + constructor(props: TruncatedTextProps) { super(props); this.state = { expanded: false }; this.toggleExpansion = this.toggleExpansion.bind(this); } - wordCount(text) { + wordCount(text: string): number | undefined { if (typeof text !== 'string') return undefined; return text .trim() @@ -17,12 +30,12 @@ class TruncatedText extends React.Component { .filter((x) => x.length).length; } - reverseText(text) { + reverseText(text: string): string { if (typeof text !== 'string' || !text.length) return text; return text.split('').reverse().join(''); } - trimInitialPunctuation(text) { + trimInitialPunctuation(text: string): string { if (typeof text !== 'string' || !text.length) return text; while (text.search(/[a-zA-Z0-9]/) !== 0) { text = text.substring(1); @@ -30,7 +43,7 @@ class TruncatedText extends React.Component { return text; } - trimPunctuation(text) { + trimPunctuation(text: string): string { if (typeof text !== 'string' || !text.length) return text; text = this.trimInitialPunctuation(text); @@ -41,31 +54,33 @@ class TruncatedText extends React.Component { return text; } - truncate(text, cutoff) { + truncate(text: string, cutoff: number): string { if (typeof text !== 'string' || typeof cutoff !== 'number') return text; - let count = this.wordCount(text); - if (count < cutoff) return text; + const count = this.wordCount(text); + if (count !== undefined && count < cutoff) return text; - let words = text + const words = text .trim() .split(' ') .filter((x) => x.length); - let threshold = Math.ceil(cutoff * 0.66); - let short = words.slice(0, threshold).join(' '); + const threshold = Math.ceil(cutoff * 0.66); + const short = words.slice(0, threshold).join(' '); return this.trimPunctuation(short) + '...'; } - toggleExpansion() { - let { expanded } = this.state; + toggleExpansion(): void { + const { expanded } = this.state; this.setState({ expanded: !expanded }); } render() { - let { expanded } = this.state; - let { className, cutoff, text } = this.props; + const { expanded } = this.state; + let className = this.props.className; + let cutoff = this.props.cutoff; + let text = this.props.text; cutoff = typeof cutoff === 'number' ? cutoff : 100; - let expandable = this.wordCount(text) > cutoff; + const expandable = (this.wordCount(text) ?? 0) > cutoff; className = 'TruncatedText' + (className ? ' ' + className : ''); text = expanded ? text : this.truncate(text, cutoff); diff --git a/packages/libs/coreui/src/components/Mesa/Defaults.jsx b/packages/libs/coreui/src/components/Mesa/Defaults.jsx deleted file mode 100644 index a1e827de7f..0000000000 --- a/packages/libs/coreui/src/components/Mesa/Defaults.jsx +++ /dev/null @@ -1,50 +0,0 @@ -export const ColumnDefaults = { - primary: false, - searchable: true, - sortable: true, - resizeable: true, - truncated: false, - - filterable: false, - filterState: { - enabled: false, - visible: false, - blacklist: [], - }, - - hideable: true, - hidden: false, - - disabled: false, - type: 'text', -}; - -export const OptionsDefaults = { - title: null, - toolbar: true, - inline: false, - className: null, - showCount: true, - errOnOverflow: false, - editableColumns: true, - overflowHeight: '16em', - searchPlaceholder: 'Search This Table', - isRowSelected: (row, index) => { - return false; - }, -}; - -export const UiStateDefaults = { - searchQuery: null, - filteredRowCount: 0, - sort: { - columnKey: null, - direction: 'asc', - }, - pagination: { - currentPage: 1, - totalPages: null, - totalRows: null, - rowsPerPage: 20, - }, -}; diff --git a/packages/libs/coreui/src/components/Mesa/Defaults.ts b/packages/libs/coreui/src/components/Mesa/Defaults.ts new file mode 100644 index 0000000000..ac6791cc99 --- /dev/null +++ b/packages/libs/coreui/src/components/Mesa/Defaults.ts @@ -0,0 +1,96 @@ +interface ColumnDefaultsType { + primary: boolean; + searchable: boolean; + sortable: boolean; + resizeable: boolean; + truncated: boolean; + filterable: boolean; + filterState: { + enabled: boolean; + visible: boolean; + blacklist: any[]; + }; + hideable: boolean; + hidden: boolean; + disabled: boolean; + type: string; +} + +interface OptionsDefaultsType { + title: string | null; + toolbar: boolean; + inline: boolean; + className: string | null; + showCount: boolean; + errOnOverflow: boolean; + editableColumns: boolean; + overflowHeight: string; + searchPlaceholder: string; + isRowSelected: (row: Row, index: number) => boolean; +} + +interface UiStateDefaultsType { + searchQuery: string | null; + filteredRowCount: number; + sort: { + columnKey: string | null; + direction: 'asc' | 'desc'; + }; + pagination: { + currentPage: number; + totalPages: number | null; + totalRows: number | null; + rowsPerPage: number; + }; +} + +export const ColumnDefaults: ColumnDefaultsType = { + primary: false, + searchable: true, + sortable: true, + resizeable: true, + truncated: false, + + filterable: false, + filterState: { + enabled: false, + visible: false, + blacklist: [], + }, + + hideable: true, + hidden: false, + + disabled: false, + type: 'text', +}; + +export const OptionsDefaults: OptionsDefaultsType = { + title: null, + toolbar: true, + inline: false, + className: null, + showCount: true, + errOnOverflow: false, + editableColumns: true, + overflowHeight: '16em', + searchPlaceholder: 'Search This Table', + isRowSelected: (row: Row, index: number): boolean => { + return false; + }, +}; + +export const UiStateDefaults: UiStateDefaultsType = { + searchQuery: null, + filteredRowCount: 0, + sort: { + columnKey: null, + direction: 'asc', + }, + pagination: { + currentPage: 1, + totalPages: null, + totalRows: null, + rowsPerPage: 20, + }, +}; diff --git a/packages/libs/coreui/src/components/Mesa/Templates.jsx b/packages/libs/coreui/src/components/Mesa/Templates.jsx deleted file mode 100644 index 921726d01a..0000000000 --- a/packages/libs/coreui/src/components/Mesa/Templates.jsx +++ /dev/null @@ -1,82 +0,0 @@ -import React from 'react'; - -import { OptionsDefaults } from './Defaults'; -import OverScroll from './Components/OverScroll'; -import TruncatedText from './Components/TruncatedText'; -import { stringValue } from './Utils/Utils'; - -const Templates = { - textCell({ key, value, row, rowIndex, column }) { - const { truncated } = column; - const className = 'Cell Cell-' + key; - const text = stringValue(value); - - return truncated ? ( - - ) : ( -
{text}
- ); - }, - - numberCell({ key, value, row, rowIndex, column }) { - const className = 'Cell NumberCell Cell-' + key; - const display = - typeof value === 'number' ? value.toLocaleString() : stringValue(value); - - return
{display}
; - }, - - wdkLinkCell({ key, value, row, rowIndex, column }) { - const className = 'Cell wdkLinkCell Cell-' + key; - let { displayText, url } = value; - let href = url ? url : '#'; - let text = displayText.length ? value.displayText : href; - text =
; - let target = '_blank'; - - const props = { href, target, className }; - - return {text}; - }, - - linkCell({ key, value, row, rowIndex, column }) { - const className = 'Cell LinkCell Cell-' + key; - const defaults = { href: null, target: '_blank', text: '' }; - let { href, target, text } = typeof value === 'object' ? value : defaults; - // href = href ? href : typeof value === 'string' ? value : '#'; - href = href ? href : typeof value === 'string' ? value : null; - text = text.length ? text : href; - - const props = { href, target, className, name: text }; - - return href && {text}; - }, - - htmlCell({ key, value, row, rowIndex, column }) { - const { truncated } = column; - const className = 'Cell HtmlCell Cell-' + key; - const content =
; - const size = truncated === true ? '16em' : truncated; - - return truncated ? ( - - {content} - - ) : ( -
{content}
- ); - }, - - heading({ key, name }) { - const className = 'Cell HeadingCell HeadingCell-' + key; - const content = {name || key}; - - return
{content}
; - }, -}; - -export default Templates; diff --git a/packages/libs/coreui/src/components/Mesa/Templates.tsx b/packages/libs/coreui/src/components/Mesa/Templates.tsx new file mode 100644 index 0000000000..e522289101 --- /dev/null +++ b/packages/libs/coreui/src/components/Mesa/Templates.tsx @@ -0,0 +1,120 @@ +import React, { ReactNode } from 'react'; + +import { OptionsDefaults } from './Defaults'; +import OverScroll from './Components/OverScroll'; +import TruncatedText from './Components/TruncatedText'; +import { stringValue } from './Utils/Utils'; +import { CellProps } from './types'; + +interface WdkLinkValue { + displayText: string; + url?: string; +} + +interface LinkValue { + href?: string | null; + target?: string; + text: string; +} + +const Templates = { + textCell({ + key, + value, + column, + }: CellProps): ReactNode { + const { truncated } = column; + const className = 'Cell Cell-' + key; + const text = stringValue(value); + + return truncated ? ( + + ) : ( +
{text}
+ ); + }, + + numberCell({ + key, + value, + }: CellProps): ReactNode { + const className = 'Cell NumberCell Cell-' + key; + const display = + typeof value === 'number' ? value.toLocaleString() : stringValue(value); + + return
{display}
; + }, + + wdkLinkCell({ + key, + value, + }: CellProps): ReactNode { + const className = 'Cell wdkLinkCell Cell-' + key; + const typedValue = value as WdkLinkValue; + let { displayText, url } = typedValue; + let href = url ? url : '#'; + let text = displayText.length ? typedValue.displayText : href; + const textElement =
; + let target = '_blank'; + + const props = { href, target, className }; + + return {textElement}; + }, + + linkCell({ key, value }: CellProps): ReactNode { + const className = 'Cell LinkCell Cell-' + key; + const defaults: LinkValue = { href: null, target: '_blank', text: '' }; + let { href, target, text } = + typeof value === 'object' && value !== null + ? (value as LinkValue) + : defaults; + const finalHref = href + ? href + : typeof value === 'string' + ? value + : undefined; + text = text.length ? text : finalHref ?? ''; + + const props = { href: finalHref, target, className, name: text }; + + return finalHref ? {text} : null; + }, + + htmlCell({ + key, + value, + column, + }: CellProps): ReactNode { + const { truncated } = column; + const className = 'Cell HtmlCell Cell-' + key; + const content = ( +
+ ); + const maxHeight = truncated === true ? '16em' : truncated; + + return truncated ? ( +
+ {content} +
+ ) : ( +
{content}
+ ); + }, + + heading({ + key, + column, + }: Pick, 'key' | 'column'>): ReactNode { + const name = column.name; + const className = 'Cell HeadingCell HeadingCell-' + key; + const content = {name || String(key)}; + + return
{content}
; + }, +}; + +export default Templates; diff --git a/packages/libs/coreui/src/components/Mesa/Ui/ActionToolbar.jsx b/packages/libs/coreui/src/components/Mesa/Ui/ActionToolbar.tsx similarity index 65% rename from packages/libs/coreui/src/components/Mesa/Ui/ActionToolbar.jsx rename to packages/libs/coreui/src/components/Mesa/Ui/ActionToolbar.tsx index 8191506ede..7d49497da7 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/ActionToolbar.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/ActionToolbar.tsx @@ -1,15 +1,22 @@ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { ReactNode } from 'react'; import SelectionCounter from './SelectionCounter'; import RowCounter from './RowCounter'; import { makeClassifier } from '../Utils/Utils'; import Toggle from '../../widgets/Toggle'; +import { MesaStateProps, MesaAction } from '../types'; const actionToolbarClass = makeClassifier('ActionToolbar'); -class ActionToolbar extends React.PureComponent { - constructor(props) { +interface ActionToolbarProps + extends Partial> { + children?: ReactNode; +} + +class ActionToolbar extends React.PureComponent< + ActionToolbarProps +> { + constructor(props: ActionToolbarProps) { super(props); this.dispatchAction = this.dispatchAction.bind(this); this.renderCounter = this.renderCounter.bind(this); @@ -19,15 +26,15 @@ class ActionToolbar extends React.PureComponent { this.renderGroupBySelectedToggle.bind(this); } - getSelection() { + getSelection(): Row[] { const { rows, options } = this.props; - const { isRowSelected } = options; + const { isRowSelected } = options || {}; - if (typeof isRowSelected !== 'function') return []; + if (!rows || typeof isRowSelected !== 'function') return []; return rows.filter(isRowSelected); } - dispatchAction(action) { + dispatchAction(action: MesaAction): void { const { handler, callback } = action; const { rows, columns } = this.props; const selection = this.getSelection(); @@ -35,12 +42,11 @@ class ActionToolbar extends React.PureComponent { if (action.selectionRequired && !selection.length) return; if (typeof handler === 'function') selection.forEach((row) => handler(row, columns)); - if (typeof callback === 'function') - return callback(selection, columns, rows); + if (typeof callback === 'function') callback(selection, columns, rows); } - renderCounter() { - const { rows = {}, options = {}, uiState = {}, eventHandlers } = this.props; + renderCounter(): ReactNode { + const { rows = [], options = {}, uiState = {}, eventHandlers } = this.props; const { showCount, toolbar } = options; if (!showCount || (showCount && toolbar)) return null; @@ -54,11 +60,17 @@ class ActionToolbar extends React.PureComponent { ); } - renderActionItem({ action }) { + renderActionItem({ + action, + key, + }: { + action: MesaAction; + key: number; + }): ReactNode { let { element } = action; let selection = this.getSelection(); let disabled = - action.selectionRequired && !selection.length ? 'disabled' : null; + action.selectionRequired && !selection.length ? 'disabled' : undefined; if (typeof element !== 'string' && !React.isValidElement(element)) { if (typeof element === 'function') element = element(selection); @@ -67,7 +79,7 @@ class ActionToolbar extends React.PureComponent { let handler = () => this.dispatchAction(action); return (
@@ -76,27 +88,33 @@ class ActionToolbar extends React.PureComponent { ); } - renderActionItemList() { + renderActionItemList(): ReactNode { const { actions } = this.props; - const ActionItem = this.renderActionItem; return (
{!actions ? null : actions .filter((action) => action.element) - .map((action, idx) => )} + .map((action, idx) => + this.renderActionItem({ action, key: idx }) + )}
); } - renderGroupBySelectedToggle() { + renderGroupBySelectedToggle(): ReactNode { const { rows, options = {}, eventHandlers = {}, uiState = {} } = this.props; const { isRowSelected } = options; const { onGroupBySelectedChange } = eventHandlers; const { groupBySelected } = uiState; - if (!isRowSelected || groupBySelected == null || !onGroupBySelectedChange) + if ( + !rows || + !isRowSelected || + groupBySelected == null || + !onGroupBySelectedChange + ) return null; return ( @@ -114,28 +132,13 @@ class ActionToolbar extends React.PureComponent { render() { const { rows, eventHandlers, children, options } = this.props; - const { selectedNoun, selectedPluralNoun, isRowSelected } = options - ? options - : {}; + const { selectedNoun, selectedPluralNoun, isRowSelected } = options || {}; const { onRowSelect, onRowDeselect, onMultipleRowSelect, onMultipleRowDeselect, - } = eventHandlers ? eventHandlers : {}; - - const ActionList = this.renderActionItemList; - - const selectionCounterProps = { - rows, - isRowSelected, - onRowSelect, - onRowDeselect, - onMultipleRowSelect, - onMultipleRowDeselect, - selectedNoun, - selectedPluralNoun, - }; + } = eventHandlers || {}; return (
@@ -145,21 +148,23 @@ class ActionToolbar extends React.PureComponent { {this.renderCounter()}
{this.renderGroupBySelectedToggle()} - + {rows && isRowSelected && ( + + )}
- + {this.renderActionItemList()}
); } } -ActionToolbar.propTypes = { - rows: PropTypes.array, - columns: PropTypes.array, - actions: PropTypes.array, - options: PropTypes.object, - eventHandlers: PropTypes.object, - children: PropTypes.oneOfType([PropTypes.node, PropTypes.element]), -}; - export default ActionToolbar; diff --git a/packages/libs/coreui/src/components/Mesa/Ui/DataCell.jsx b/packages/libs/coreui/src/components/Mesa/Ui/DataCell.tsx similarity index 50% rename from packages/libs/coreui/src/components/Mesa/Ui/DataCell.jsx rename to packages/libs/coreui/src/components/Mesa/Ui/DataCell.tsx index fa88237b2a..710c52c8ff 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/DataCell.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/DataCell.tsx @@ -1,31 +1,54 @@ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { ReactNode } from 'react'; import Templates from '../Templates'; import { makeClassifier } from '../Utils/Utils'; +import { MesaColumn, MesaStateProps, CellProps } from '../types'; import { Tooltip } from '../../../components/info/Tooltip'; const dataCellClass = makeClassifier('DataCell'); -class DataCell extends React.PureComponent { - constructor(props) { +interface DataCellProps, Key = string> { + column: MesaColumn; + row: Row; + inline?: boolean; + options?: MesaStateProps['options']; + rowIndex: number; + columnIndex: number | null; + isChildRow?: boolean; + childRowColSpan?: number; +} + +class DataCell< + Row extends Record, + Key = string +> extends React.PureComponent> { + constructor(props: DataCellProps) { super(props); this.renderContent = this.renderContent.bind(this); } - renderContent() { + renderContent(): ReactNode { const { row, column, rowIndex, columnIndex, inline, options, isChildRow } = this.props; const { key, getValue } = column; const value = - typeof getValue === 'function' ? getValue({ row, key }) : row[key]; - const cellProps = { key, value, row, column, rowIndex, columnIndex }; - const { childRow } = options; + typeof getValue === 'function' + ? getValue({ row, key }) + : row[key as unknown as PropertyKey]; + const cellProps = { + key, + value, + row, + column, + rowIndex, + columnIndex: columnIndex ?? 0, + } as CellProps; + const { childRow } = options || {}; if (isChildRow && childRow != null) { return childRow(rowIndex, row); } - if ('renderCell' in column) { + if ('renderCell' in column && column.renderCell) { return column.renderCell(cellProps); } @@ -49,7 +72,7 @@ class DataCell extends React.PureComponent { } } - setTitle(el) { + setTitle(el?: HTMLTableCellElement | null): void { if (el == null) return; el.title = el.scrollWidth <= el.clientWidth ? '' : el.innerText; } @@ -64,28 +87,34 @@ class DataCell extends React.PureComponent { : { textOverflow: 'ellipsis', overflow: 'hidden', - maxWidth: options.inlineMaxWidth ? options.inlineMaxWidth : '20vw', - maxHeight: options.inlineMaxHeight ? options.inlineMaxHeight : '2em', + maxWidth: + options && options.inlineMaxWidth ? options.inlineMaxWidth : '20vw', + maxHeight: + options && options.inlineMaxHeight + ? options.inlineMaxHeight + : '2em', }; - width = typeof width === 'number' ? width + 'px' : width; - width = width ? { width, maxWidth: width, minWidth: width } : {}; - style = Object.assign({}, style, width, whiteSpace); - className = dataCellClass() + (className ? ' ' + className : ''); + const widthValue = typeof width === 'number' ? width + 'px' : width; + const widthStyle = widthValue + ? { width: widthValue, maxWidth: widthValue, minWidth: widthValue } + : {}; + const finalStyle = Object.assign({}, style, widthStyle, whiteSpace); + const finalClassName = dataCellClass() + (className ? ' ' + className : ''); const content = this.renderContent(); const props = { - style, + style: finalStyle, children: content, - className, + className: finalClassName, ...(isChildRow ? { colSpan: childRowColSpan } : null), }; return column.hidden ? null : ( this.setTitle(e.target)} - onMouseLeave={() => this.setTitle()} + onMouseEnter={(e) => this.setTitle(e.target as HTMLTableCellElement)} + onMouseLeave={() => this.setTitle(null)} key={key + '_' + rowIndex} {...props} /> @@ -93,13 +122,4 @@ class DataCell extends React.PureComponent { } } -DataCell.propTypes = { - column: PropTypes.object, - row: PropTypes.object, - inline: PropTypes.bool, - options: PropTypes.object, - rowIndex: PropTypes.number, - columnIndex: PropTypes.number, -}; - export default DataCell; diff --git a/packages/libs/coreui/src/components/Mesa/Ui/DataRow.jsx b/packages/libs/coreui/src/components/Mesa/Ui/DataRow.tsx similarity index 61% rename from packages/libs/coreui/src/components/Mesa/Ui/DataRow.jsx rename to packages/libs/coreui/src/components/Mesa/Ui/DataRow.tsx index 59f4f8cd6e..8d222260a0 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/DataRow.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/DataRow.tsx @@ -1,18 +1,31 @@ import React from 'react'; -import PropTypes from 'prop-types'; import DataCell from './DataCell'; import SelectionCell from './SelectionCell'; import ExpansionCell from './ExpansionCell'; import { makeClassifier } from '../Utils/Utils'; +import { MesaStateProps } from '../types'; const dataRowClass = makeClassifier('DataRow'); const EXTRA_COLUMNS_FOR_EXPAND_AND_SELECT = 2; const EXTRA_COLUMNS_FOR_EXPAND = 1; -class DataRow extends React.PureComponent { - constructor(props) { +interface DataRowProps, Key = string> + extends MesaStateProps { + row: Row; + rowIndex: number; +} + +interface DataRowState { + expanded: boolean; +} + +class DataRow< + Row extends Record, + Key = string +> extends React.PureComponent, DataRowState> { + constructor(props: DataRowProps) { super(props); this.state = { expanded: false }; this.handleRowClick = this.handleRowClick.bind(this); @@ -23,25 +36,26 @@ class DataRow extends React.PureComponent { this.componentWillReceiveProps = this.componentWillReceiveProps.bind(this); } - componentWillReceiveProps(newProps) { + componentWillReceiveProps(newProps: DataRowProps): void { const { row } = this.props; if (newProps.row !== row) this.collapseRow(); } - expandRow() { + expandRow(): void { const { options } = this.props; - if (!options.inline || options.inlineUseTooltips) return; + if (!options || !options.inline || options.inlineUseTooltips) return; this.setState({ expanded: true }); } - collapseRow() { + collapseRow(): void { const { options } = this.props; - if (!options.inline || options.inlineUseTooltips) return; + if (!options || !options.inline || options.inlineUseTooltips) return; this.setState({ expanded: false }); } - handleRowClick() { + handleRowClick(): void { const { row, rowIndex, options } = this.props; + if (!options) return; const { inline, onRowClick, inlineUseTooltips } = options; if (!inline && !onRowClick) return; if (inline && !inlineUseTooltips) @@ -49,8 +63,9 @@ class DataRow extends React.PureComponent { if (typeof onRowClick === 'function') onRowClick(row, rowIndex); } - handleRowMouseOver() { + handleRowMouseOver(): void { const { row, rowIndex, options } = this.props; + if (!options) return; const { onRowMouseOver } = options; if (typeof onRowMouseOver === 'function') { @@ -58,8 +73,9 @@ class DataRow extends React.PureComponent { } } - handleRowMouseOut() { + handleRowMouseOut(): void { const { row, rowIndex, options } = this.props; + if (!options) return; const { onRowMouseOut } = options; if (typeof onRowMouseOut === 'function') { @@ -71,22 +87,44 @@ class DataRow extends React.PureComponent { const { row, rowIndex, columns, options, eventHandlers, uiState } = this.props; const { expanded } = this.state; - const { columnDefaults, childRow, getRowId } = options ? options : {}; - const inline = options.inline ? !expanded : false; + const { columnDefaults, childRow, getRowId } = options ?? {}; + const inline = options && options.inline ? !expanded : false; - const hasSelectionColumn = + const selectionProps = + options && + eventHandlers && typeof options.isRowSelected === 'function' && typeof eventHandlers.onRowSelect === 'function' && - typeof eventHandlers.onRowDeselect === 'function'; + typeof eventHandlers.onRowDeselect === 'function' + ? { + isRowSelected: options.isRowSelected, + options, + eventHandlers, + } + : null; + + const hasSelectionColumn = selectionProps != null; - const hasExpansionColumn = + const expansionProps = childRow != null && eventHandlers?.onExpandedRowsChange != null && uiState?.expandedRows != null && - getRowId != null; + getRowId != null + ? { + onExpandedRowsChange: eventHandlers.onExpandedRowsChange, + expandedRows: uiState.expandedRows, + getRowId: getRowId, + } + : null; + + const hasExpansionColumn = expansionProps != null; const showChildRow = - hasExpansionColumn && uiState.expandedRows.includes(getRowId(row)); + hasExpansionColumn && + uiState && + uiState.expandedRows && + getRowId && + uiState.expandedRows.includes(getRowId(row)); const childRowColSpan = columns.length + (hasSelectionColumn @@ -95,10 +133,10 @@ class DataRow extends React.PureComponent { const rowStyle = !inline ? {} - : { whiteSpace: 'nowrap', textOverflow: 'ellipsis' }; - let className = dataRowClass(null, inline ? 'inline' : ''); + : { whiteSpace: 'nowrap' as const, textOverflow: 'ellipsis' as const }; + let className = dataRowClass(undefined, inline ? 'inline' : ''); - const { deriveRowClassName } = options; + const { deriveRowClassName } = options || {}; if (typeof deriveRowClassName === 'function') { let derivedClassName = deriveRowClassName(row); className += @@ -114,36 +152,33 @@ class DataRow extends React.PureComponent { className={className .concat(showChildRow ? ' _childIsExpanded' : '') .concat(hasExpansionColumn ? ' _isExpandable' : '')} - tabIndex={this.props.options.onRowClick ? -1 : undefined} + tabIndex={this.props.options?.onRowClick ? -1 : undefined} style={rowStyle} onClick={this.handleRowClick} onMouseOver={this.handleRowMouseOver} onMouseOut={this.handleRowMouseOut} > - {hasExpansionColumn && ( + {expansionProps && ( )} - {hasSelectionColumn && ( - + {selectionProps && ( + )} {columns.map((column, columnIndex) => { + let finalColumn = column; if (typeof columnDefaults === 'object') - column = Object.assign({}, columnDefaults, column); + finalColumn = Object.assign({}, columnDefaults, column); return ( @@ -153,7 +188,7 @@ class DataRow extends React.PureComponent { {showChildRow && ( - {filteredRows.map((row, rowIndex) => ( - - ))} - - ); - } -} - -export default DataRowList; diff --git a/packages/libs/coreui/src/components/Mesa/Ui/DataRowList.tsx b/packages/libs/coreui/src/components/Mesa/Ui/DataRowList.tsx new file mode 100644 index 0000000000..a4afe969bf --- /dev/null +++ b/packages/libs/coreui/src/components/Mesa/Ui/DataRowList.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +import DataRow from './DataRow'; +import { MesaStateProps } from '../types'; + +interface DataRowListProps, Key = string> + extends MesaStateProps {} + +class DataRowList< + Row extends Record, + Key = string +> extends React.Component> { + constructor(props: DataRowListProps) { + super(props); + } + + render() { + const { props } = this; + const { filteredRows } = props; + + return ( + + {filteredRows && + filteredRows.map((row, rowIndex) => ( + + ))} + + ); + } +} + +export default DataRowList; diff --git a/packages/libs/coreui/src/components/Mesa/Ui/DataTable.jsx b/packages/libs/coreui/src/components/Mesa/Ui/DataTable.tsx similarity index 66% rename from packages/libs/coreui/src/components/Mesa/Ui/DataTable.jsx rename to packages/libs/coreui/src/components/Mesa/Ui/DataTable.tsx index 919c0e2378..69a1c55efe 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/DataTable.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/DataTable.tsx @@ -1,17 +1,32 @@ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { CSSProperties } from 'react'; import { isEqual, sum } from 'lodash'; -import { defaultMemoize } from 'reselect'; import HeadingRow from './HeadingRow'; import DataRowList from './DataRowList'; import { makeClassifier, combineWidths } from '../Utils/Utils'; +import { MesaStateProps, MesaColumn } from '../types'; const dataTableClass = makeClassifier('DataTable'); -class DataTable extends React.Component { - constructor(props) { +interface DataTableProps, Key = string> + extends MesaStateProps {} + +interface DataTableState { + dynamicWidths?: number[] | null; +} + +class DataTable< + Row extends Record, + Key = string +> extends React.Component, DataTableState> { + widthCache: number[] = []; + resizeId: number = -1; + mainRef: HTMLDivElement | null = null; + contentTable: HTMLTableElement | null = null; + cachedWidth?: number; + + constructor(props: DataTableProps) { super(props); this.widthCache = []; this.renderStickyTable = this.renderStickyTable.bind(this); @@ -25,18 +40,23 @@ class DataTable extends React.Component { this.mainRef = null; } - shouldUseStickyHeader() { + shouldUseStickyHeader(): boolean { const { options } = this.props; if (!options || !options.useStickyHeader) return false; - if (!options.tableBodyMaxHeight) - return console.error(` + if (!options.tableBodyMaxHeight) { + console.error(` "useStickyHeader" option enabled but no maxHeight for the table is set. Use a css height as the "tableBodyMaxHeight" option to use this setting. `); + return false; + } return true; } - makeFirstNColumnsSticky(columns, n) { + makeFirstNColumnsSticky( + columns: MesaColumn[], + n: number + ): MesaColumn[] { const dynamicWidths = this.widthCache; if (n <= columns.length) { @@ -50,13 +70,13 @@ class DataTable extends React.Component { moveable: false, headingStyle: { ...column.headingStyle, - position: 'sticky', + position: 'sticky' as const, left: `${leftOffset}px`, zIndex: 2, }, style: { ...column.style, - position: 'sticky', + position: 'sticky' as const, left: `${leftOffset}px`, zIndex: 1, }, @@ -70,13 +90,13 @@ class DataTable extends React.Component { return columns; } - componentDidMount() { + componentDidMount(): void { this.setDynamicWidths(); this.attachLoadEventHandlers(); this.attachResizeHandler(); } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps: DataTableProps): void { if ( this.props.columns.map((c) => c.name).toString() !== prevProps.columns.map((c) => c.name).toString() || @@ -87,39 +107,43 @@ class DataTable extends React.Component { } } - componentWillUnmount() { + componentWillUnmount(): void { this.removeResizeHandler(); } - attachLoadEventHandlers() { + attachLoadEventHandlers(): void { if (this.contentTable == null) return; this.contentTable .querySelectorAll('img, iframe, object') .forEach((node) => { - if (node.complete) return; + if ((node as HTMLImageElement).complete) return; node.addEventListener('load', (event) => { - const el = event.target.offsetParent || event.target; - if (el.scrollWidth > el.clientWidth) this.setDynamicWidths(); + const el = (event.target as HTMLElement).offsetParent || event.target; + if ((el as HTMLElement).scrollWidth > (el as HTMLElement).clientWidth) + this.setDynamicWidths(); }); }); } - attachResizeHandler() { + attachResizeHandler(): void { this.resizeId = setInterval(() => { if (this.mainRef == null || this.cachedWidth === this.mainRef.clientWidth) return; this.setDynamicWidths(); this.cachedWidth = this.mainRef.clientWidth; - }, 250); + }, 250) as unknown as number; } - removeResizeHandler() { + removeResizeHandler(): void { clearInterval(this.resizeId); } - setDynamicWidths() { + setDynamicWidths(): void { // noop if rows or filteredRows is empty - if (this.props.rows.length === 0 || this.props.filteredRows.length === 0) + if ( + this.props.rows.length === 0 || + (this.props.filteredRows && this.props.filteredRows.length === 0) + ) return; this.setState({ dynamicWidths: null }, () => { @@ -140,16 +164,18 @@ class DataTable extends React.Component { }); } - hasExpansionColumn() { + hasExpansionColumn(): boolean { const { options, eventHandlers } = this.props; + if (!options || !eventHandlers) return false; return ( typeof options.childRow === 'function' && typeof eventHandlers.onExpandedRowsChange === 'function' ); } - hasSelectionColumn() { + hasSelectionColumn(): boolean { const { options, eventHandlers } = this.props; + if (!options || !eventHandlers) return false; return ( typeof options.isRowSelected === 'function' && typeof eventHandlers.onRowSelect === 'function' && @@ -170,13 +196,14 @@ class DataTable extends React.Component { uiState, headerWrapperStyle, } = this.props; - const newColumns = options.useStickyFirstNColumns - ? this.makeFirstNColumnsSticky(columns, options.useStickyFirstNColumns) - : columns; - const wrapperStyle = { - maxHeight: options ? options.tableBodyMaxHeight : null, + const newColumns = + options && options.useStickyFirstNColumns + ? this.makeFirstNColumnsSticky(columns, options.useStickyFirstNColumns) + : columns; + const wrapperStyle: CSSProperties = { + maxHeight: options ? options.tableBodyMaxHeight : undefined, }; - const tableStyle = { + const tableStyle: CSSProperties = { tableLayout: 'auto', }; const tableProps = { @@ -191,10 +218,13 @@ class DataTable extends React.Component { return (
(this.mainRef = node)} className="MesaComponent">
x !== undefined) + )} style={wrapperStyle} >
- {this.props.options.marginContent && ( + {this.props.options && this.props.options.marginContent && (
{this.props.options.marginContent}
@@ -221,25 +251,4 @@ class DataTable extends React.Component { } } -DataTable.propTypes = { - rows: PropTypes.array, - filteredRows: PropTypes.array, - headerWrapperStyle: PropTypes.object, - columns: PropTypes.array, - options: PropTypes.object, - actions: PropTypes.arrayOf( - PropTypes.shape({ - element: PropTypes.oneOfType([ - PropTypes.func, - PropTypes.node, - PropTypes.element, - ]), - handler: PropTypes.func, - callback: PropTypes.func, - }) - ), - uiState: PropTypes.object, - eventHandlers: PropTypes.objectOf(PropTypes.func), -}; - export default DataTable; diff --git a/packages/libs/coreui/src/components/Mesa/Ui/EmptyState.jsx b/packages/libs/coreui/src/components/Mesa/Ui/EmptyState.tsx similarity index 81% rename from packages/libs/coreui/src/components/Mesa/Ui/EmptyState.jsx rename to packages/libs/coreui/src/components/Mesa/Ui/EmptyState.tsx index c7d93bd54b..f187664bf6 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/EmptyState.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/EmptyState.tsx @@ -1,15 +1,27 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import Icon from '../Components/Icon'; import { makeClassifier } from '../Utils/Utils'; -class EmptyState extends React.PureComponent { - constructor(props) { +const emptyStateClass = makeClassifier('EmptyState'); + +interface EmptyStateProps { + culprit?: 'search' | 'nocolumns' | 'filters' | 'nodata'; +} + +interface CulpritInfo { + icon: string; + title: string; + content: ReactNode; +} + +class EmptyState extends React.PureComponent { + constructor(props: EmptyStateProps) { super(props); this.getCulprit = this.getCulprit.bind(this); } - getCulprit() { + getCulprit(): CulpritInfo { const { culprit } = this.props; switch (culprit) { case 'search': @@ -59,7 +71,6 @@ class EmptyState extends React.PureComponent { render() { const culprit = this.getCulprit(); - const emptyStateClass = makeClassifier('EmptyState'); return (
diff --git a/packages/libs/coreui/src/components/Mesa/Ui/ExpansionCell.tsx b/packages/libs/coreui/src/components/Mesa/Ui/ExpansionCell.tsx index b58cbe2bb3..61752d4d08 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/ExpansionCell.tsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/ExpansionCell.tsx @@ -1,19 +1,19 @@ import React, { useCallback } from 'react'; import { ArrowDown, ArrowRight } from '../../icons'; -type Props = { - rows: unknown[]; - row: unknown; +type Props = { + rows: Row[]; + row: Row; onExpandedRowsChange: (ids: (string | number)[]) => void; expandedRows: (string | number)[]; - getRowId: (row: unknown) => string | number; + getRowId: (row: Row) => string | number; inert: boolean; heading: boolean; }; -const EMPTY_ARRAY: Props['expandedRows'] = []; +const EMPTY_ARRAY: (string | number)[] = []; -export default function ExpansionCell({ +export default function ExpansionCell({ rows, row, onExpandedRowsChange, @@ -21,7 +21,7 @@ export default function ExpansionCell({ getRowId, inert, heading, -}: Props) { +}: Props) { const expandAllRows = useCallback( () => onExpandedRowsChange(rows.map((row) => getRowId(row))), [onExpandedRowsChange, rows, getRowId] diff --git a/packages/libs/coreui/src/components/Mesa/Ui/HeadingCell.jsx b/packages/libs/coreui/src/components/Mesa/Ui/HeadingCell.tsx similarity index 64% rename from packages/libs/coreui/src/components/Mesa/Ui/HeadingCell.jsx rename to packages/libs/coreui/src/components/Mesa/Ui/HeadingCell.tsx index 0bbc62c6c4..2007120b3e 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/HeadingCell.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/HeadingCell.tsx @@ -1,16 +1,38 @@ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { CSSProperties, ReactElement } from 'react'; import Templates from '../Templates'; import Icon from '../Components/Icon'; import HelpTrigger from '../Components/HelpTrigger'; import { makeClassifier } from '../Utils/Utils'; import Events, { EventsFactory } from '../Utils/Events'; +import { MesaColumn, MesaSortObject, MesaStateProps } from '../types'; const headingCellClass = makeClassifier('HeadingCell'); -class HeadingCell extends React.PureComponent { - constructor(props) { +interface HeadingCellProps, Key = string> { + sort?: MesaSortObject; + eventHandlers?: MesaStateProps['eventHandlers']; + column: MesaColumn; + columnIndex: number; + primary?: boolean; + headingRowIndex?: number; + offsetLeft?: number; +} + +interface HeadingCellState { + offset: DOMRect | null; + isDragging: boolean; + isDragTarget: boolean; +} + +class HeadingCell< + Row extends Record, + Key = string +> extends React.PureComponent, HeadingCellState> { + private element?: HTMLTableCellElement; + private listeners?: { [key: string]: string }; + + constructor(props: HeadingCellProps) { super(props); this.state = { offset: null, @@ -45,12 +67,14 @@ class HeadingCell extends React.PureComponent { } componentWillUnmount() { - Object.values(this.listeners).forEach((listenerId) => - Events.remove(listenerId) - ); + if (this.listeners) { + Object.values(this.listeners).forEach((listenerId) => + Events.remove(listenerId) + ); + } } - componentdidUpdate(prevProps) { + componentDidUpdate(prevProps: HeadingCellProps) { if ( prevProps.column !== this.props.column || prevProps.column.width !== this.props.column.width @@ -68,9 +92,9 @@ class HeadingCell extends React.PureComponent { sortColumn() { const { column, sort, eventHandlers } = this.props; - const { onSort } = eventHandlers; + const { onSort } = eventHandlers ?? {}; if (typeof onSort !== 'function' || !column.sortable) return; - const currentlySorting = sort && sort.columnKey === column.key; + const currentlySorting = sort && sort.columnKey === String(column.key); const direction = currentlySorting && sort.direction === 'asc' ? 'desc' : 'asc'; return onSort(column, direction); @@ -78,7 +102,7 @@ class HeadingCell extends React.PureComponent { // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= - wrapContent(content = null) { + wrapContent(content: React.ReactNode = null) { const SortTrigger = this.renderSortTrigger; const HelpTrigger = this.renderHelpTrigger; const ClickBoundary = this.renderClickBoundary; @@ -109,7 +133,7 @@ class HeadingCell extends React.PureComponent { !('renderHeading' in column) || typeof column.renderHeading !== 'function' ) - return this.wrapContent(Templates.heading(column, columnIndex)); + return this.wrapContent(Templates.heading({ key: column.key, column })); const content = column.renderHeading(column, columnIndex, { SortTrigger, @@ -119,15 +143,23 @@ class HeadingCell extends React.PureComponent { const { wrapCustomHeadings } = column; const shouldWrap = wrapCustomHeadings && typeof wrapCustomHeadings === 'function' - ? wrapCustomHeadings({ column, columnIndex, headingRowIndex }) + ? wrapCustomHeadings({ + column, + columnIndex, + headerRowIndex: headingRowIndex ?? 0, + }) : wrapCustomHeadings; return shouldWrap ? this.wrapContent(content) : content; } - renderClickBoundary({ children }) { - const style = { display: 'inline-block' }; - const stopPropagation = (node) => { + renderClickBoundary({ + children, + }: { + children: React.ReactNode; + }): ReactElement { + const style: CSSProperties = { display: 'inline-block' }; + const stopPropagation = (node: HTMLDivElement | null) => { if (!node) return null; const instance = EventsFactory(node); instance.add('click', (e) => { @@ -141,12 +173,12 @@ class HeadingCell extends React.PureComponent { ); } - renderSortTrigger() { + renderSortTrigger(): ReactElement | null { const { column, sort, eventHandlers } = this.props; - const { columnKey, direction } = sort ? sort : {}; - const { key, sortable } = column ? column : {}; - const { onSort } = eventHandlers ? eventHandlers : {}; - const isActive = columnKey === key; + const { columnKey, direction } = sort ?? {}; + const { key, sortable } = column ?? {}; + const { onSort } = eventHandlers ?? {}; + const isActive = columnKey === String(key); if (!sortable || (typeof onSort !== 'function' && !isActive)) return null; @@ -175,58 +207,61 @@ class HeadingCell extends React.PureComponent { ); } - renderHelpTrigger() { + renderHelpTrigger(): ReactElement | null { const { column } = this.props; - if (!column.helpText && !column.htmlHelp) return null; - return {column.htmlHelp ?? column.helpText}; + if (!column.helpText && !('htmlHelp' in column)) return null; + return ( + + {('htmlHelp' in column && column.htmlHelp) ?? column.helpText} + + ); } // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= - onDragStart(event) { + onDragStart(event: React.DragEvent) { const { key } = this.props.column; event.dataTransfer.effectAllowed = 'copy'; - event.dataTransfer.setData('text', key); + event.dataTransfer.setData('text', String(key)); this.setState({ isDragging: true }); return event; } - onDragEnd(event) { + onDragEnd(event: React.DragEvent) { this.setState({ isDragging: false, isDragTarget: false }); - this.element.blur(); + this.element?.blur(); event.preventDefault(); } - onDragEnter(event) { - const dragee = event.dataTransfer.getData('text'); + onDragEnter(event: React.DragEvent) { if (!this.state.isDragTarget) this.setState({ isDragTarget: true }); event.preventDefault(); } - onDragExit(event) { + onDragExit(event: React.DragEvent) { this.setState({ isDragTarget: false }); event.preventDefault(); } - onDragOver(event) { + onDragOver(event: React.DragEvent) { event.preventDefault(); } - onDragLeave(event) { + onDragLeave(event: React.DragEvent) { this.setState({ isDragTarget: false }); - this.element.blur(); + this.element?.blur(); event.preventDefault(); } - onDrop(event) { - this.element.blur(); + onDrop(event: React.DragEvent) { + this.element?.blur(); event.preventDefault(); const { eventHandlers, columnIndex } = this.props; - const { onColumnReorder } = eventHandlers; + const { onColumnReorder } = eventHandlers ?? {}; if (typeof onColumnReorder !== 'function') return; const draggedColumn = event.dataTransfer.getData('text'); if (this.state.isDragTarget) this.setState({ isDragTarget: false }); - onColumnReorder(draggedColumn, columnIndex); + onColumnReorder(draggedColumn as unknown as Key, columnIndex); } getDomEvents() { @@ -253,26 +288,33 @@ class HeadingCell extends React.PureComponent { getClassName() { const { key, className } = this.props.column; const { isDragging, isDragTarget } = this.state; - const modifiers = ['key-' + key]; + const modifiers = ['key-' + String(key)]; if (isDragging) modifiers.push('Dragging'); if (isDragTarget) modifiers.push('DragTarget'); return ( (typeof className === 'string' ? className + ' ' : '') + - headingCellClass(null, modifiers) + headingCellClass(undefined, modifiers) ); } render() { const { column, eventHandlers, primary } = this.props; - const { key, headingStyle, width } = column; - const widthStyle = width ? { width, maxWidth: width, minWidth: width } : {}; - - const style = Object.assign( - {}, - headingStyle ? headingStyle : {}, - widthStyle - ); - const ref = (element) => (this.element = element); + const { key, width } = column; + const headingStyle = + 'headingStyle' in column + ? (column.headingStyle as CSSProperties) + : undefined; + const widthStyle: CSSProperties = width + ? { width, maxWidth: width, minWidth: width } + : {}; + + const style: CSSProperties = { + ...(headingStyle ?? {}), + ...widthStyle, + }; + const ref = (element: HTMLTableCellElement | null) => { + if (element) this.element = element; + }; const children = this.renderContent(); const className = this.getClassName(); @@ -282,21 +324,14 @@ class HeadingCell extends React.PureComponent { primary && column.moveable && !column.primary && - typeof eventHandlers.onColumnReorder === 'function'; + typeof eventHandlers?.onColumnReorder === 'function'; const props = { style, ref, draggable, children, className }; - return column.hidden ? null : ; + const hidden = 'hidden' in column ? column.hidden : false; + + return hidden ? null : ; } } -HeadingCell.propTypes = { - sort: PropTypes.object, - eventHandlers: PropTypes.object, - column: PropTypes.object.isRequired, - columnIndex: PropTypes.number.isRequired, - primary: PropTypes.bool, - headingRowIndex: PropTypes.number, -}; - export default HeadingCell; diff --git a/packages/libs/coreui/src/components/Mesa/Ui/HeadingRow.jsx b/packages/libs/coreui/src/components/Mesa/Ui/HeadingRow.tsx similarity index 51% rename from packages/libs/coreui/src/components/Mesa/Ui/HeadingRow.jsx rename to packages/libs/coreui/src/components/Mesa/Ui/HeadingRow.tsx index c92e8e71d6..18c528a40a 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/HeadingRow.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/HeadingRow.tsx @@ -1,31 +1,52 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import ExpansionCell from './ExpansionCell'; import HeadingCell from './HeadingCell'; import SelectionCell from './SelectionCell'; +import { MesaStateProps, MesaColumn } from '../types'; -class HeadingRow extends React.PureComponent { - constructor(props) { - super(props); - } +interface HeadingRowProps, Key = string> + extends Pick< + MesaStateProps, + 'columns' | 'uiState' | 'eventHandlers' | 'options' | 'actions' + > { + filteredRows?: Row[]; + offsetLeft?: number; +} + +type RenderHeadingFunction, Key = string> = + ( + column: MesaColumn, + columnIndex: number, + components: any + ) => ReactNode; + +type HeadingRowColumn, Key = string> = + MesaColumn & { + renderHeading?: + | boolean + | RenderHeadingFunction + | Array>; // Multi-row headers: each array element becomes a separate header row + }; +class HeadingRow< + Row extends Record, + Key = string +> extends React.PureComponent> { render() { const { - filteredRows, + filteredRows: filteredRowsProp, options, columns, - actions, uiState, eventHandlers, offsetLeft, } = this.props; - const { isRowSelected, columnDefaults, childRow, getRowId } = options - ? options - : {}; - const { sort, expandedRows } = uiState ? uiState : {}; - const { onRowSelect, onRowDeselect, onExpandedRowsChange } = eventHandlers - ? eventHandlers - : {}; + const filteredRows = filteredRowsProp ?? []; + const { isRowSelected, columnDefaults, childRow, getRowId } = options ?? {}; + const { sort, expandedRows } = uiState ?? {}; + const { onRowSelect, onRowDeselect, onExpandedRowsChange } = + eventHandlers ?? {}; const hasSelectionColumn = [ isRowSelected, onRowSelect, @@ -39,23 +60,33 @@ class HeadingRow extends React.PureComponent { getRowId != null; const rowCount = columns.reduce((count, column) => { - const thisCount = Array.isArray(column.renderHeading) - ? column.renderHeading.length + const col = column as HeadingRowColumn; + const thisCount = Array.isArray(col.renderHeading) + ? col.renderHeading.length : 1; return Math.max(thisCount, count); }, 1); - const headingRows = new Array(rowCount).fill({}).map((blank, index) => { + const headingRows = new Array(rowCount).fill({}).map((_blank, index) => { const isFirstRow = !index; - const cols = columns.map((col) => { - const output = Object.assign({}, col); - if (Array.isArray(col.renderHeading)) { - output.renderHeading = - col.renderHeading.length > index ? col.renderHeading[index] : false; - } else if (!isFirstRow) { - output.renderHeading = false; + const cols = columns.map((col): MesaColumn => { + const column = col as HeadingRowColumn; + + if (Array.isArray(column.renderHeading)) { + return { + ...column, + renderHeading: + column.renderHeading.length > index + ? column.renderHeading[index] + : false, + }; + } + + if (!isFirstRow) { + return { ...column, renderHeading: false }; } - return output; + + return column; }); return { cols, isFirstRow }; }); @@ -65,19 +96,19 @@ class HeadingRow extends React.PureComponent { {headingRows.map(({ cols, isFirstRow }, index) => { return ( - {hasExpansionColumn && ( + {hasExpansionColumn && getRowId && ( )} - {hasSelectionColumn && ( + {hasSelectionColumn && isRowSelected && ( )} {cols.map((column, columnIndex) => { + let mergedColumn = column; if (typeof columnDefaults === 'object') - column = Object.assign({}, columnDefaults, column); + mergedColumn = { ...columnDefaults, ...column }; return ( , + Key = string +> extends MesaStateProps { + children?: ReactNode; + headerWrapperStyle?: React.CSSProperties; +} -class MesaController extends React.Component { - constructor(props) { +class MesaController< + Row extends Record, + Key = string +> extends React.Component> { + constructor(props: MesaControllerProps) { super(props); this.renderToolbar = this.renderToolbar.bind(this); this.renderActionBar = this.renderActionBar.bind(this); @@ -59,8 +70,8 @@ class MesaController extends React.Component { renderEmptyState() { const { uiState, options } = this.props; - const { emptinessCulprit } = uiState ? uiState : {}; - const { renderEmptyState } = options ? options : {}; + const { emptinessCulprit } = uiState ?? {}; + const { renderEmptyState } = options ?? {}; return renderEmptyState ? ( renderEmptyState() @@ -95,22 +106,21 @@ class MesaController extends React.Component { const Toolbar = this.renderToolbar; const ActionBar = this.renderActionBar; const PageNav = this.renderPaginationMenu; - const Empty = this.renderEmptyState; - const className = (options.className ?? '') + ' Mesa MesaComponent'; + const className = (options?.className ?? '') + ' Mesa MesaComponent'; return ( -
+
{rows.length ? ( - {filteredRows.length ? null : } + {filteredRows.length ? null : this.renderEmptyState()} ) : ( - + this.renderEmptyState() )}
@@ -118,24 +128,4 @@ class MesaController extends React.Component { } } -MesaController.propTypes = { - rows: PropTypes.array.isRequired, - columns: PropTypes.array.isRequired, - filteredRows: PropTypes.array, - options: PropTypes.object, - actions: PropTypes.arrayOf( - PropTypes.shape({ - element: PropTypes.oneOfType([ - PropTypes.func, - PropTypes.node, - PropTypes.element, - ]), - handler: PropTypes.func, - callback: PropTypes.func, - }) - ), - uiState: PropTypes.object, - eventHandlers: PropTypes.objectOf(PropTypes.func), -}; - export default MesaController; diff --git a/packages/libs/coreui/src/components/Mesa/Ui/PaginationMenu.jsx b/packages/libs/coreui/src/components/Mesa/Ui/PaginationMenu.tsx similarity index 78% rename from packages/libs/coreui/src/components/Mesa/Ui/PaginationMenu.jsx rename to packages/libs/coreui/src/components/Mesa/Ui/PaginationMenu.tsx index fdcfea9cb1..a4d73d5b57 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/PaginationMenu.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/PaginationMenu.tsx @@ -1,6 +1,5 @@ import { range } from 'lodash'; import React from 'react'; -import PropTypes from 'prop-types'; import Icon from '../Components/Icon'; import RowsPerPageMenu from './RowsPerPageMenu'; @@ -10,8 +9,27 @@ const settings = { innerRadius: 2, }; -class PaginationMenu extends React.PureComponent { - constructor(props) { +interface PaginationMenuProps { + totalRows?: number; + totalPages?: number; + currentPage?: number; + rowsPerPage?: number; + onPageChange?: (page: number) => void; + rowsPerPageOptions?: number[]; + onRowsPerPageChange?: (numRows: number) => void; +} + +type RelativePageType = + | 'first' + | 'start' + | 'last' + | 'end' + | 'next' + | 'prev' + | 'previous'; + +class PaginationMenu extends React.PureComponent { + constructor(props: PaginationMenuProps) { super(props); this.renderEllipsis = this.renderEllipsis.bind(this); this.renderPageLink = this.renderPageLink.bind(this); @@ -20,7 +38,7 @@ class PaginationMenu extends React.PureComponent { this.renderRelativeLink = this.renderRelativeLink.bind(this); } - renderEllipsis(key = '') { + renderEllipsis(key: string | number = '') { return (
... @@ -28,7 +46,7 @@ class PaginationMenu extends React.PureComponent { ); } - renderPageLink(page, current) { + renderPageLink(page: number, current?: number) { let handler = () => this.goToPage(page); return (
); diff --git a/packages/libs/eda/src/lib/core/components/filter/TableFilter.tsx b/packages/libs/eda/src/lib/core/components/filter/TableFilter.tsx index 66cf569a73..89bda1214f 100644 --- a/packages/libs/eda/src/lib/core/components/filter/TableFilter.tsx +++ b/packages/libs/eda/src/lib/core/components/filter/TableFilter.tsx @@ -133,12 +133,15 @@ export function TableFilter({ const activeField = useMemo( () => ({ // add units - display: variableDisplayWithUnit(variable), - isRange: false, + display: variableDisplayWithUnit(variable) ?? variable.displayName, + isRange: false as const, parent: variable.parentId, precision: 1, term: variable.id, - type: variable.type, + type: (variable.type === 'integer' ? 'number' : variable.type) as + | 'string' + | 'number' + | 'date', variableName: variable.providerLabel, }), [variable] @@ -209,13 +212,15 @@ export function TableFilter({ () => ({ loading: false, summary: { - valueCounts: sortedDistribution, - internalsCount: tableSummary.value?.entitiesCount, - internalsFilteredCount: tableSummary.value?.filteredEntitiesCount, + term: variable.id, + valueCounts: sortedDistribution ?? [], + internalsCount: tableSummary.value?.entitiesCount ?? 0, + internalsFilteredCount: tableSummary.value?.filteredEntitiesCount ?? 0, }, ...uiState, }), [ + variable.id, sortedDistribution, tableSummary.value?.entitiesCount, tableSummary.value?.filteredEntitiesCount, @@ -337,7 +342,6 @@ export function TableFilter({ = { overlayValues: string[]; onChange: (configuration: T) => void; @@ -24,7 +26,7 @@ type Props = { selectedCountsOption: SelectedCountsOption; }; -const DEFAULT_SORTING: MesaSortObject = { +const DEFAULT_SORTING: MesaSortObject = { columnKey: 'count', direction: 'desc', }; @@ -42,7 +44,7 @@ export function CategoricalMarkerConfigurationTable< allCategoricalValues = [], selectedCountsOption, }: Props) { - const [sort, setSort] = useState(DEFAULT_SORTING); + const [sort, setSort] = useState>(DEFAULT_SORTING); const totalCount = allCategoricalValues?.reduce( (prev, curr) => prev + curr.count, 0 @@ -117,10 +119,7 @@ export function CategoricalMarkerConfigurationTable< }); } - const tableState: MesaStateProps< - AllValuesDefinition, - keyof AllValuesDefinition | 'distribution' - > = { + const tableState: MesaStateProps = { options: { isRowSelected: (value: AllValuesDefinition) => uncontrolledSelections.has(value.label), @@ -168,7 +167,7 @@ export function CategoricalMarkerConfigurationTable< }); }, onSort: ( - { key: columnKey }: { key: string }, + { key: columnKey }: { key: ColumnKey }, direction: MesaSortObject['direction'] ) => setSort({ columnKey, direction }), }, diff --git a/packages/libs/eda/src/lib/workspace/PublicAnalyses.tsx b/packages/libs/eda/src/lib/workspace/PublicAnalyses.tsx index 8d398cfeb9..523b7a7690 100644 --- a/packages/libs/eda/src/lib/workspace/PublicAnalyses.tsx +++ b/packages/libs/eda/src/lib/workspace/PublicAnalyses.tsx @@ -245,7 +245,7 @@ function PublicAnalysesTable({ [filteredRows, tableSort, exampleSort, offerExampleSortControl] ); - const columns: MesaColumn[] = useMemo( + const columns: MesaColumn[] = useMemo( () => [ { key: 'studyId', diff --git a/packages/libs/multi-blast/src/lib/components/BlastWorkspaceAll.tsx b/packages/libs/multi-blast/src/lib/components/BlastWorkspaceAll.tsx index 555ee559a1..6825fd525a 100644 --- a/packages/libs/multi-blast/src/lib/components/BlastWorkspaceAll.tsx +++ b/packages/libs/multi-blast/src/lib/components/BlastWorkspaceAll.tsx @@ -50,7 +50,7 @@ interface AllJobsTableProps { export function AllJobsTable(props: AllJobsTableProps) { const allJobsColumns = useAllJobsColumns(); - const [sort, setSort] = useState({ + const [sort, setSort] = useState>({ columnKey: 'created', direction: 'desc', }); diff --git a/packages/libs/multi-blast/src/lib/hooks/allJobs.tsx b/packages/libs/multi-blast/src/lib/hooks/allJobs.tsx index 1811211bc4..9fa938c538 100644 --- a/packages/libs/multi-blast/src/lib/hooks/allJobs.tsx +++ b/packages/libs/multi-blast/src/lib/hooks/allJobs.tsx @@ -100,12 +100,12 @@ export function useSortedJobRows(unsortedRows: JobRow[], sort: MesaSortObject) { ); } -export function useMesaUiState(sort: MesaSortObject) { +export function useMesaUiState(sort: MesaSortObject) { return useMemo(() => ({ sort }), [sort]); } export function useMesaEventHandlers( - setSort: (newSort: MesaSortObject) => void + setSort: (newSort: MesaSortObject) => void ) { return useMemo( () => ({ diff --git a/packages/libs/multi-blast/src/lib/hooks/combinedResults.tsx b/packages/libs/multi-blast/src/lib/hooks/combinedResults.tsx index 5d01c431ed..d00c45916e 100644 --- a/packages/libs/multi-blast/src/lib/hooks/combinedResults.tsx +++ b/packages/libs/multi-blast/src/lib/hooks/combinedResults.tsx @@ -95,7 +95,7 @@ export function useCombinedResultProps({ filesToOrganisms ); - const [sort, setSort] = useState({ + const [sort, setSort] = useState>({ columnKey: 'queryRank', direction: 'asc', }); @@ -236,7 +236,7 @@ function useCombinedResultColumns( jobId: string, organismToProject: Record, projectUrls: Record -): MesaColumn[] { +): MesaColumn[] { const targetMetadataByDataType = useContext(TargetMetadataByDataType); const recordLinkUrlSegment = @@ -574,7 +574,9 @@ function useSortedCombinedResultRows( return sortedRows; } -function useMesaEventHandlers(setSort: (newSort: MesaSortObject) => void) { +function useMesaEventHandlers( + setSort: (newSort: MesaSortObject) => void +) { return useMemo( () => ({ onSort: ( @@ -588,7 +590,7 @@ function useMesaEventHandlers(setSort: (newSort: MesaSortObject) => void) { ); } -function useMesaUiState(sort: MesaSortObject) { +function useMesaUiState(sort: MesaSortObject) { return useMemo(() => ({ sort }), [sort]); } diff --git a/packages/libs/study-data-access/src/study-access/components/UserTable.tsx b/packages/libs/study-data-access/src/study-access/components/UserTable.tsx index d1d6d67fb5..8bedd44421 100644 --- a/packages/libs/study-data-access/src/study-access/components/UserTable.tsx +++ b/packages/libs/study-data-access/src/study-access/components/UserTable.tsx @@ -7,6 +7,7 @@ import Mesa, { MesaState } from '@veupathdb/coreui/lib/components/Mesa'; import { MesaColumn, MesaSortObject, + MesaStateProps, } from '@veupathdb/coreui/lib/components/Mesa/types'; import { Seq } from '@veupathdb/wdk-client/lib/Utils/IterableUtils'; import { @@ -16,7 +17,10 @@ import { import { cx } from './StudyAccess'; -export interface Props> { +export interface Props< + R extends Record, + C extends UserTableColumnKey +> { rows: R[]; columns: UserTableColumns; columnOrder: readonly C[]; @@ -28,34 +32,38 @@ export interface Props> { }[]; } -export type UserTableColumnKey = keyof R & string; +export type UserTableColumnKey> = keyof R & + string; -export interface UserTableSortObject> - extends MesaSortObject { +export interface UserTableSortObject< + R extends Record, + K extends UserTableColumnKey +> extends MesaSortObject { columnKey: K; direction: 'asc' | 'desc'; } type OrderablePrimimitive = boolean | number | string; -export interface UserTableColumn> - extends MesaColumn { +export interface UserTableColumn< + R extends Record, + K extends UserTableColumnKey +> extends MesaColumn { makeSearchableString?: (value: R[K], row: R) => string; makeOrder?: (row: R) => OrderablePrimimitive | OrderablePrimimitive[]; } -export type UserTableColumns> = { +export type UserTableColumns< + R extends Record, + C extends UserTableColumnKey +> = { [K in C]: UserTableColumn; }; -export function UserTable>({ - actions, - columnOrder, - columns, - rows, - idGetter, - initialSort, -}: Props) { +export function UserTable< + R extends Record, + C extends UserTableColumnKey +>({ actions, columnOrder, columns, rows, idGetter, initialSort }: Props) { const [selectedRowIds, setSelectedRowIds] = useState( () => new Set() ); @@ -114,7 +122,7 @@ export function UserTable>({ actions, options: mesaOptions, eventHandlers: mesaEventHandlers, - uiState: mesaUiState, + uiState: mesaUiState as MesaStateProps['uiState'], }), [ actions, @@ -143,7 +151,10 @@ export function UserTable>({ ); } -function makeMesaRows>( +function makeMesaRows< + R extends Record, + C extends UserTableColumnKey +>( rows: Props['rows'], columns: Props['columns'], sortUiState: UserTableSortObject @@ -157,7 +168,10 @@ function makeMesaRows>( : orderBy(rows, makeOrder, sortDirection); } -function useMesaFilteredRows>( +function useMesaFilteredRows< + R extends Record, + C extends UserTableColumnKey +>( rows: Props['rows'], columns: Props['columns'], columnOrder: Props['columnOrder'], @@ -201,14 +215,17 @@ function useMesaFilteredRows>( ); } -function makeMesaColumns>( - columns: Props['columns'], - columnOrder: Props['columnOrder'] -) { +function makeMesaColumns< + R extends Record, + C extends UserTableColumnKey +>(columns: Props['columns'], columnOrder: Props['columnOrder']) { return columnOrder.map((columnKey) => columns[columnKey]); } -function makeMesaEventHandlers>( +function makeMesaEventHandlers< + R extends Record, + C extends UserTableColumnKey +>( setSortUiState: (newSort: UserTableSortObject) => void, selectedRowIds: Set, setSelectedRowIds: (newSelectedRowIds: Set) => void, @@ -261,15 +278,19 @@ function makeMesaEventHandlers>( }; } -function makeMesaUiState>( - sort: UserTableSortObject -) { +function makeMesaUiState< + R extends Record, + C extends UserTableColumnKey +>(sort: UserTableSortObject) { return { sort, }; } -function makeMesaOptions>( +function makeMesaOptions< + R extends Record, + C extends UserTableColumnKey +>( selectedRowIds: Set, idGetter: Props['idGetter'], actions: Props['actions'] diff --git a/packages/libs/study-data-access/src/study-access/components/UserTableSection.tsx b/packages/libs/study-data-access/src/study-access/components/UserTableSection.tsx index 1e16279bb2..87a5e528c2 100644 --- a/packages/libs/study-data-access/src/study-access/components/UserTableSection.tsx +++ b/packages/libs/study-data-access/src/study-access/components/UserTableSection.tsx @@ -7,7 +7,10 @@ import { Props as UserTableProps, } from './UserTable'; -export type Props> = +export type Props< + R extends Record, + C extends UserTableColumnKey +> = | { status: 'loading'; } @@ -24,9 +27,10 @@ export type Props> = value: UserTableProps; }; -export function UserTableSection>( - props: Props -) { +export function UserTableSection< + R extends Record, + C extends UserTableColumnKey +>(props: Props) { return props.status === 'loading' || props.status === 'unavailable' ? null : props.status === 'error' ? (

{props.message}

diff --git a/packages/libs/user-datasets/src/lib/Components/List/UserDatasetList.tsx b/packages/libs/user-datasets/src/lib/Components/List/UserDatasetList.tsx index 4a8aa87b44..02fbef2671 100644 --- a/packages/libs/user-datasets/src/lib/Components/List/UserDatasetList.tsx +++ b/packages/libs/user-datasets/src/lib/Components/List/UserDatasetList.tsx @@ -92,7 +92,7 @@ interface Props { interface State { selectedRows: Array; - uiState: { sort: MesaSortObject }; + uiState: { sort: MesaSortObject }; searchTerm: string; editingCache: any; } @@ -398,15 +398,16 @@ class UserDatasetList extends React.Component { this.setState({ selectedRows: newSelection }); } - onSort(column: MesaColumn, direction: string): void { + onSort(column: MesaColumn, direction: 'asc' | 'desc'): void { const key = column.key; - const { state } = this; - const { setSortColumnKey, setSortDirection } = MesaState; - const updatedState = setSortDirection( - setSortColumnKey(state, key), - direction - ); - this.setState(updatedState); + this.setState({ + uiState: { + sort: { + columnKey: key, + direction: direction, + }, + }, + }); } getEventHandlers() { @@ -543,7 +544,7 @@ class UserDatasetList extends React.Component { filterAndSortRows(rows: UserDataset[]): UserDataset[] { const { searchTerm, uiState } = this.state; const { projectName, filterByProject } = this.props; - const sort: MesaSortObject = uiState.sort; + const sort: MesaSortObject = uiState.sort; if (filterByProject) rows = rows.filter((dataset) => dataset.projects.includes(projectName)); if (searchTerm && searchTerm.length) @@ -567,12 +568,11 @@ class UserDatasetList extends React.Component { if (columnKey === null) return (data: any) => data; switch (columnKey) { case 'type': - return (data: UserDataset, index: number): string => - data.type.display.toLowerCase(); + return (data: UserDataset): string => data.type.display.toLowerCase(); case 'meta.name': return (data: UserDataset) => data.meta.name.toLowerCase(); default: - return (data: any, index: number) => { + return (data: any) => { return typeof data[columnKey] !== 'undefined' ? data[columnKey] : null; @@ -582,7 +582,7 @@ class UserDatasetList extends React.Component { sortRowsByColumnKey( rows: UserDataset[], - sort: MesaSortObject + sort: MesaSortObject ): UserDataset[] { const direction: string = sort.direction; const columnKey: string = sort.columnKey; @@ -648,7 +648,7 @@ class UserDatasetList extends React.Component { eventHandlers, uiState: { ...uiState, - emptinessCulprit: userDatasets.length ? 'search' : null, + emptinessCulprit: userDatasets.length ? ('search' as const) : undefined, }, }; diff --git a/packages/libs/wdk-client/src/Actions/FavoritesActions.ts b/packages/libs/wdk-client/src/Actions/FavoritesActions.ts index 2cc5948635..ee2d2d6a78 100644 --- a/packages/libs/wdk-client/src/Actions/FavoritesActions.ts +++ b/packages/libs/wdk-client/src/Actions/FavoritesActions.ts @@ -2,12 +2,12 @@ import { ActionThunk } from '../Core/WdkMiddleware'; import { Favorite, RecordClass } from '../Utils/WdkModel'; import { ServiceError } from '../Service/ServiceError'; import { MesaState } from '@veupathdb/coreui/lib/components/Mesa'; +import type { MesaStateProps } from '@veupathdb/coreui/lib/components/Mesa/types'; // Types // ----- -// FIXME Determine the actual type from Mesa -type TableState = {}; +type TableState = Partial>; export type Action = | SortTableAction @@ -472,7 +472,7 @@ export function loadFavoritesList(): ActionThunk { startFavoritesRequest(), wdkService.getCurrentFavorites().then( (rows) => { - const newTableState = MesaState.create({ rows }); + const newTableState = MesaState.create({ rows }) as TableState; return endFavoritesRequestWithSucess(newTableState); }, (error: ServiceError) => endFavoritesRequestWithError(error) @@ -482,11 +482,11 @@ export function loadFavoritesList(): ActionThunk { } export function saveCellData( - tableState: {}, + tableState: TableState, updatedFavorite: Favorite ): ActionThunk { return ({ wdkService }) => { - const rows = MesaState.getRows(tableState); + const rows = MesaState.getRows(tableState) as Favorite[]; const updatedRows = rows.map((fav: Favorite) => fav.id === updatedFavorite.id ? updatedFavorite : fav ); @@ -503,12 +503,12 @@ export function saveCellData( } export function deleteFavorites( - tableState: {}, + tableState: TableState, deletedFavorites: Favorite[] ): ActionThunk { return ({ wdkService }) => { const deletedIds = deletedFavorites.map((fav: Favorite) => fav.id); - const rows = MesaState.getRows(tableState); + const rows = MesaState.getRows(tableState) as Favorite[]; const updatedRows = rows.filter( (fav: Favorite) => !deletedIds.includes(fav.id) ); @@ -525,12 +525,12 @@ export function deleteFavorites( } export function undeleteFavorites( - tableState: {}, + tableState: TableState, undeletedFavorites: Favorite[] ): ActionThunk { return ({ wdkService }) => { const ids = undeletedFavorites.map((favorite) => favorite.id); - const rows = MesaState.getRows(tableState); + const rows = MesaState.getRows(tableState) as Favorite[]; const updatedTableState = MesaState.setRows(tableState, [ ...rows, ...undeletedFavorites, diff --git a/packages/libs/wdk-client/src/Components/AttributeFilter/DateField.jsx b/packages/libs/wdk-client/src/Components/AttributeFilter/DateField.tsx similarity index 51% rename from packages/libs/wdk-client/src/Components/AttributeFilter/DateField.jsx rename to packages/libs/wdk-client/src/Components/AttributeFilter/DateField.tsx index 0125ae72bf..b60b380152 100644 --- a/packages/libs/wdk-client/src/Components/AttributeFilter/DateField.jsx +++ b/packages/libs/wdk-client/src/Components/AttributeFilter/DateField.tsx @@ -7,16 +7,65 @@ import { formatDate, parseDate, } from '../../Components/AttributeFilter/AttributeFilterUtils'; +import { + Field, + RangeFilter, + OntologyTermSummary, + ValueCounts, +} from '../../Components/AttributeFilter/Types'; + +/** + * Distribution entry type for histogram data + */ +interface DistributionEntry { + value: number | string | null; + count: number; + filteredCount: number; +} + +/** + * Props for the DateField component + */ +interface DateFieldProps { + distribution: DistributionEntry[]; + toFilterValue: (value: number) => number | string; + toHistogramValue: (value: number | string) => number; + selectByDefault: boolean; + onChange: ( + activeField: Field, + range: { min?: number | string | null; max?: number | string | null }, + includeUnknown: boolean, + valueCounts: ValueCounts + ) => void; + activeField: Field; + activeFieldState: { + summary: OntologyTermSummary; + [key: string]: any; + }; + filter?: RangeFilter; + displayName: string; + unknownCount: number; + onRangeScaleChange?: (activeField: Field, range: any) => void; + histogramTruncateYAxisDefault?: boolean; + histogramScaleYAxisDefault?: boolean; +} /** * Date field component */ -export default class DateField extends React.Component { - static getHelpContent(props) { - return HistogramField.getHelpContent(props); +export default class DateField extends React.Component { + timeformat?: string; + + static getHelpContent(props: DateFieldProps) { + return ( +
+ Select a range of {props.activeField.display} values with the graph + below. +
+ ); } - constructor(props) { + constructor(props: DateFieldProps) { super(props); this.toHistogramValue = this.toHistogramValue.bind(this); this.toFilterValue = this.toFilterValue.bind(this); @@ -26,11 +75,11 @@ export default class DateField extends React.Component { this.setDateFormat(this.props.activeFieldState.summary.valueCounts); } - componentWillUpdate(nextProps) { + componentWillUpdate(nextProps: DateFieldProps) { this.setDateFormat(nextProps.activeFieldState.summary.valueCounts); } - setDateFormat(distribution) { + setDateFormat(distribution: ValueCounts) { const firstDateEntry = distribution.find((entry) => entry.value != null); if (firstDateEntry == null) { console.warn( @@ -38,19 +87,24 @@ export default class DateField extends React.Component { distribution ); } else { - this.timeformat = getFormatFromDateString(firstDateEntry.value); + this.timeformat = getFormatFromDateString(firstDateEntry.value as string); } } - toHistogramValue(value) { + toHistogramValue(value: number | string): number { const date = typeof value === 'string' ? parseDate(value) : new Date(value); return date.getTime(); } - toFilterValue(value) { + toFilterValue(value: number): number | string { switch (typeof value) { case 'number': - return formatDate(this.timeformat, new Date(value)); + // TODO: this.timeformat can be undefined if setDateFormat() finds no non-null + // distribution entries, which will cause formatDate() to crash. Should either: + // 1) Add a fallback format (e.g., '%Y-%m-%d' - note: strftime format, not 'yyyy-MM-dd') + // 2) Handle this edge case earlier in the component lifecycle + // 3) Prevent rendering DateField when there's no valid date data + return formatDate(this.timeformat!, new Date(value)); default: return value; } @@ -68,7 +122,7 @@ export default class DateField extends React.Component { knownDist .filter((entry) => entry.filteredCount > 0) .map((entry) => entry.value), - (value) => parseDate(value).getTime() + (value) => parseDate(value as string | number).getTime() ); var distMin = values[0]; var distMax = values[values.length - 1]; @@ -76,7 +130,7 @@ export default class DateField extends React.Component { var dateDist = knownDist.map(function (entry) { // convert value to time in ms return Object.assign({}, entry, { - value: parseDate(entry.value).getTime(), + value: parseDate(entry.value as string | number).getTime(), }); }); diff --git a/packages/libs/wdk-client/src/Components/AttributeFilter/EmptyField.jsx b/packages/libs/wdk-client/src/Components/AttributeFilter/EmptyField.tsx similarity index 74% rename from packages/libs/wdk-client/src/Components/AttributeFilter/EmptyField.jsx rename to packages/libs/wdk-client/src/Components/AttributeFilter/EmptyField.tsx index 70526ef05b..7bed47d3f0 100644 --- a/packages/libs/wdk-client/src/Components/AttributeFilter/EmptyField.jsx +++ b/packages/libs/wdk-client/src/Components/AttributeFilter/EmptyField.tsx @@ -1,9 +1,10 @@ import React from 'react'; -/** - * Empty field component - */ -export default function EmptyField(props) { +interface EmptyFieldProps { + displayName: string; +} + +export default function EmptyField(props: EmptyFieldProps) { return (

diff --git a/packages/libs/wdk-client/src/Components/AttributeFilter/EmptyValues.jsx b/packages/libs/wdk-client/src/Components/AttributeFilter/EmptyValues.tsx similarity index 69% rename from packages/libs/wdk-client/src/Components/AttributeFilter/EmptyValues.jsx rename to packages/libs/wdk-client/src/Components/AttributeFilter/EmptyValues.tsx index 7b2ab6a7f0..7c97bb5ad4 100644 --- a/packages/libs/wdk-client/src/Components/AttributeFilter/EmptyValues.jsx +++ b/packages/libs/wdk-client/src/Components/AttributeFilter/EmptyValues.tsx @@ -3,8 +3,13 @@ import React from 'react'; import Icon from '../../Components/Icon/IconAlt'; import { isRange } from '../../Components/AttributeFilter/AttributeFilterUtils'; +import { Field } from '../../Components/AttributeFilter/Types'; -export default function EmptyValue(props) { +interface EmptyValueProps { + activeField: Field; +} + +export default function EmptyValue(props: EmptyValueProps) { return (
diff --git a/packages/libs/wdk-client/src/Components/AttributeFilter/FieldFilter.jsx b/packages/libs/wdk-client/src/Components/AttributeFilter/FieldFilter.tsx similarity index 63% rename from packages/libs/wdk-client/src/Components/AttributeFilter/FieldFilter.jsx rename to packages/libs/wdk-client/src/Components/AttributeFilter/FieldFilter.tsx index 419eb09954..b119e96e13 100644 --- a/packages/libs/wdk-client/src/Components/AttributeFilter/FieldFilter.jsx +++ b/packages/libs/wdk-client/src/Components/AttributeFilter/FieldFilter.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import { makeClassNameHelper } from '../../Utils/ComponentUtils'; import Icon from '../../Components/Icon/IconAlt'; @@ -6,18 +5,56 @@ import EmptyField from '../../Components/AttributeFilter/EmptyField'; import MultiFieldFilter from '../../Components/AttributeFilter/MultiFieldFilter'; import SingleFieldFilter from '../../Components/AttributeFilter/SingleFieldFilter'; import { isMulti } from '../../Components/AttributeFilter/AttributeFilterUtils'; +import { + Field, + Filter, + FieldTreeNode, + OntologyTermSummary, +} from '../../Components/AttributeFilter/Types'; const cx = makeClassNameHelper('field-detail'); + +/** + * State for a particular field filter + */ +type FieldFilterState = { + loading?: boolean; + summary?: OntologyTermSummary; + leafSummaries?: OntologyTermSummary[]; + errorMessage?: string; + // member, range, multi specific settings + [key: string]: any; +}; + +/** + * Props for the FieldFilter component + */ +type FieldFilterProps = { + displayName?: string; + dataCount?: number | null; + filteredDataCount?: number; + filters?: Filter[]; + activeField?: Field | null; + activeFieldState?: FieldFilterState; + fieldTree?: FieldTreeNode; + onFiltersChange?: (filters: Filter[]) => void; + onMemberSort?: (sortBy: string) => void; + onMemberSearch?: (searchTerm: string) => void; + onRangeScaleChange?: (scale: string) => void; + hideFieldPanel?: boolean; + selectByDefault: boolean; +}; + /** * Main interactive filtering interface for a particular field. */ -function FieldFilter(props) { +function FieldFilter(props: FieldFilterProps) { let className = cx('', props.hideFieldPanel && 'fullWidth'); return (
{!props.activeField ? ( - + ) : (

@@ -48,9 +85,9 @@ function FieldFilter(props) { props.activeFieldState.summary == null && props.activeFieldState.leafSummaries == null) || props.dataCount == null ? null : isMulti(props.activeField) ? ( - + ) : ( - + )} )} @@ -58,43 +95,6 @@ function FieldFilter(props) { ); } -const FieldSummary = PropTypes.shape({ - valueCounts: PropTypes.array.isRequired, - internalsCount: PropTypes.number.isRequired, - internalsFilteredCount: PropTypes.number.isRequired, -}); - -const MultiFieldSummary = PropTypes.arrayOf( - PropTypes.shape({ - term: PropTypes.string.isRequired, - valueCounts: PropTypes.array.isRequired, - internalsCount: PropTypes.number.isRequired, - internalsFilteredCount: PropTypes.number.isRequired, - }) -); - -FieldFilter.propTypes = { - displayName: PropTypes.string, - dataCount: PropTypes.number, - filteredDataCount: PropTypes.number, - filters: PropTypes.array, - activeField: PropTypes.object, - activeFieldState: PropTypes.shape({ - loading: PropTypes.boolean, - summary: FieldSummary, - leafSummaries: MultiFieldSummary, - /* member, range, multi specific settings */ - }), - - onFiltersChange: PropTypes.func, - onMemberSort: PropTypes.func, - onMemberSearch: PropTypes.func, - onRangeScaleChange: PropTypes.func, - - hideFieldPanel: PropTypes.bool, - selectByDefault: PropTypes.bool.isRequired, -}; - FieldFilter.defaultProps = { displayName: 'Items', }; diff --git a/packages/libs/wdk-client/src/Components/AttributeFilter/FieldList.jsx b/packages/libs/wdk-client/src/Components/AttributeFilter/FieldList.tsx similarity index 59% rename from packages/libs/wdk-client/src/Components/AttributeFilter/FieldList.jsx rename to packages/libs/wdk-client/src/Components/AttributeFilter/FieldList.tsx index a1e1fac7df..a14232f980 100644 --- a/packages/libs/wdk-client/src/Components/AttributeFilter/FieldList.jsx +++ b/packages/libs/wdk-client/src/Components/AttributeFilter/FieldList.tsx @@ -1,5 +1,4 @@ import { memoize, uniq } from 'lodash'; -import PropTypes from 'prop-types'; import React, { useLayoutEffect, useRef } from 'react'; import ReactDOM from 'react-dom'; import { scrollIntoViewIfNeeded } from '../../Utils/DomUtils'; @@ -17,14 +16,35 @@ import { isRange, findAncestorFields, } from '../../Components/AttributeFilter/AttributeFilterUtils'; +import { + Field, + FieldTreeNode, + TreeNode, +} from '../../Components/AttributeFilter/Types'; + +interface FieldListProps { + autoFocus?: boolean; + fieldTree: FieldTreeNode; + onActiveFieldChange: (term: string) => void; + activeField?: Field | null; + valuesMap: Record; +} + +interface FieldListState { + searchTerm: string; + expandedNodes: string[]; +} /** * Tree of Fields, used to set the active field. */ -export default class FieldList extends React.Component { - // eslint-disable-line react/no-deprecated +export default class FieldList extends React.Component< + FieldListProps, + FieldListState +> { + treeDomNode?: Element | Text | null; - constructor(props) { + constructor(props: FieldListProps) { super(props); this.handleCheckboxTreeRef = this.handleCheckboxTreeRef.bind(this); this.getNodeId = this.getNodeId.bind(this); @@ -43,7 +63,7 @@ export default class FieldList extends React.Component { }; } - componentWillReceiveProps(nextProps) { + componentWillReceiveProps(nextProps: FieldListProps) { if ( nextProps.activeField == null || this.props.activeField === nextProps.activeField @@ -64,15 +84,15 @@ export default class FieldList extends React.Component { } } - handleCheckboxTreeRef(component) { + handleCheckboxTreeRef(component: any) { this.treeDomNode = ReactDOM.findDOMNode(component); } - handleExpansionChange(expandedNodes) { + handleExpansionChange(expandedNodes: string[]) { this.setState({ expandedNodes }); } - handleFieldSelect(node) { + handleFieldSelect(node: TreeNode) { this.props.onActiveFieldChange(node.field.term); const expandedNodes = Seq.from(this.state.expandedNodes) .concat(this._getPathToField(node.field)) @@ -82,19 +102,20 @@ export default class FieldList extends React.Component { this.setState({ expandedNodes }); } - handleSearchTermChange(searchTerm) { + handleSearchTermChange(searchTerm: string) { // update search term, then if it is empty, make sure selected field is visible this.setState({ searchTerm }); } - getNodeId(node) { + + getNodeId(node: TreeNode) { return node.field.term; } - getNodeChildren(node) { + getNodeChildren(node: TreeNode) { return isMulti(node.field) ? [] : node.children; } - getFieldSearchString(node) { + getFieldSearchString(node: TreeNode): string { return isMulti(node.field) ? preorderSeq(node) .map(getNodeSearchString(this.props.valuesMap)) @@ -102,11 +123,11 @@ export default class FieldList extends React.Component { : getNodeSearchString(this.props.valuesMap)(node); } - searchPredicate(node, searchTerms) { + searchPredicate(node: TreeNode, searchTerms: string[]) { return areTermsInString(searchTerms, this.getFieldSearchString(node)); } - _getPathToField(field) { + _getPathToField(field: Field | null | undefined): string[] { if (field == null) return []; return findAncestorFields(this.props.fieldTree, field.term) @@ -114,75 +135,80 @@ export default class FieldList extends React.Component { .toArray(); } - render() { - var { activeField, autoFocus, fieldTree } = this.props; + render(): JSX.Element { + const { activeField, autoFocus, fieldTree } = this.props; return (
- ( - - )} - linksPosition={LinksPosition.Top} - styleOverrides={{ - treeNode: { - nodeWrapper: { - padding: 0, + > + {...({ + ref: this.handleCheckboxTreeRef, + autoFocusSearchBox: autoFocus, + tree: fieldTree, + expandedList: this.state.expandedNodes, + getNodeId: this.getNodeId, + getNodeChildren: this.getNodeChildren, + onExpansionChange: this.handleExpansionChange, + isSelectable: false, + isSearchable: true, + searchBoxPlaceholder: 'Find a variable', + searchBoxHelp: makeSearchHelpText( + 'the variables by name or description' + ), + searchTerm: this.state.searchTerm, + onSearchTermChange: this.handleSearchTermChange, + searchPredicate: this.searchPredicate, + renderNode: (node: FieldTreeNode) => ( + + ), + linksPosition: LinksPosition.Top, + styleOverrides: { + treeNode: { + nodeWrapper: { + padding: 0, + }, }, }, - }} + } as any)} />
); } } -FieldList.propTypes = { - autoFocus: PropTypes.bool, - fieldTree: PropTypes.object.isRequired, - onActiveFieldChange: PropTypes.func.isRequired, - activeField: PropTypes.object, - valuesMap: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.string).isRequired) - .isRequired, -}; - -function getNodeSearchString(valuesMap) { +function getNodeSearchString(valuesMap: Record) { return function ({ field: { term, display = '', description = '', variableName = '' }, - }) { + }: TreeNode): string { return `${display} ${description} ${variableName} ${ valuesMap[term] || '' }`.toLowerCase(); }; } -function FieldNode({ node, isActive, searchTerm, handleFieldSelect }) { - const nodeRef = useRef(null); +interface FieldNodeProps { + node: TreeNode; + isActive: boolean; + searchTerm: string; + handleFieldSelect: (node: TreeNode) => void; +} + +function FieldNode({ + node, + isActive, + searchTerm, + handleFieldSelect, +}: FieldNodeProps): JSX.Element { + const nodeRef = useRef(null); useLayoutEffect(() => { if (isActive && nodeRef.current && nodeRef.current.offsetParent) { - scrollIntoViewIfNeeded(nodeRef.current.offsetParent); + scrollIntoViewIfNeeded(nodeRef.current.offsetParent as HTMLElement); } }, [isActive, nodeRef.current, searchTerm]); @@ -213,6 +239,6 @@ function FieldNode({ node, isActive, searchTerm, handleFieldSelect }) { ); } -function getIcon(field) { +function getIcon(field: Field): string { return isRange(field) ? 'bar-chart-o' : isMulti(field) ? 'th-list' : 'list'; } diff --git a/packages/libs/wdk-client/src/Components/AttributeFilter/FilterLegend.jsx b/packages/libs/wdk-client/src/Components/AttributeFilter/FilterLegend.tsx similarity index 79% rename from packages/libs/wdk-client/src/Components/AttributeFilter/FilterLegend.jsx rename to packages/libs/wdk-client/src/Components/AttributeFilter/FilterLegend.tsx index ca65ece9fc..22ab4a9234 100644 --- a/packages/libs/wdk-client/src/Components/AttributeFilter/FilterLegend.jsx +++ b/packages/libs/wdk-client/src/Components/AttributeFilter/FilterLegend.tsx @@ -1,9 +1,20 @@ import React from 'react'; +import { Field } from './Types'; + +/** + * Props for the FilterLegend component + */ +interface FilterLegendProps { + displayName?: string; + activeField?: Field; +} /** * Legend used for all filters */ -export default function FilterLegend(props) { +export default function FilterLegend( + props: FilterLegendProps +): React.ReactElement { return (
@@ -11,7 +22,7 @@ export default function FilterLegend(props) {
- All {props.displayName} having "{props.activeField.display}" + All {props.displayName} having "{props.activeField?.display}"
@@ -26,6 +37,7 @@ export default function FilterLegend(props) {
); } + // TODO Either remove the commented code below, or replace using provided total counts // const totalCounts = Seq.from(props.distribution) // // FIXME Always filter nulls when they are moved to different section for non-range fields @@ -51,7 +63,10 @@ export default function FilterLegend(props) { */ // FIXME Remove eslint rule when counts and percentages are figured out // eslint-disable-next-line no-unused-vars, require-jsdoc -function concatCounts(countsA, countsB) { +function concatCounts( + countsA: { count: number; filteredCount: number }, + countsB: { count: number; filteredCount: number } +): { count: number; filteredCount: number } { return { count: countsA.count + countsB.count, filteredCount: countsA.filteredCount + countsB.filteredCount, diff --git a/packages/libs/wdk-client/src/Components/AttributeFilter/FilterList.jsx b/packages/libs/wdk-client/src/Components/AttributeFilter/FilterList.tsx similarity index 75% rename from packages/libs/wdk-client/src/Components/AttributeFilter/FilterList.jsx rename to packages/libs/wdk-client/src/Components/AttributeFilter/FilterList.tsx index 0a2ab17605..6f7dd30605 100644 --- a/packages/libs/wdk-client/src/Components/AttributeFilter/FilterList.jsx +++ b/packages/libs/wdk-client/src/Components/AttributeFilter/FilterList.tsx @@ -1,5 +1,4 @@ import { partial } from 'lodash'; -import PropTypes from 'prop-types'; import React from 'react'; import { Seq } from '../../Utils/IterableUtils'; import IconAlt from '../../Components/Icon/IconAlt'; @@ -9,6 +8,21 @@ import { shouldAddFilter, } from '../../Components/AttributeFilter/AttributeFilterUtils'; import { postorderSeq } from '../../Utils/TreeUtils'; +import { Filter, FieldTreeNode, Field, MultiFilterValue } from './Types'; + +interface FilterListProps { + onActiveFieldChange: (field: string) => void; + onFiltersChange: (filters: Filter[]) => void; + fieldTree: FieldTreeNode; + filters: Filter[]; + displayName: string; + dataCount?: number; + filteredDataCount?: number; + hideGlobalCounts: boolean; + loadingFilteredCount?: boolean; + activeField?: Field; + minSelectedCount?: number; +} /** * List of filters configured by the user. @@ -16,12 +30,12 @@ import { postorderSeq } from '../../Utils/TreeUtils'; * Each filter can be used to update the active field * or to remove a filter. */ -export default class FilterList extends React.Component { +export default class FilterList extends React.Component { /** * @param {FilterListProps} props * @return {React.Component} */ - constructor(props) { + constructor(props: FilterListProps) { super(props); this.handleFilterSelectClick = this.handleFilterSelectClick.bind(this); this.handleFilterRemoveClick = this.handleFilterRemoveClick.bind(this); @@ -31,56 +45,65 @@ export default class FilterList extends React.Component { * @param {Filter} filter * @param {Event} event */ - handleFilterSelectClick(filter, containerFilter = filter, event) { + handleFilterSelectClick = ( + filter: Filter, + containerFilter: Filter = filter, + event: React.MouseEvent + ) => { event.preventDefault(); this.props.onActiveFieldChange(containerFilter.field); - } + }; /** * @param {Filter} filter * @param {Event} event */ - handleFilterRemoveClick(filter, containerFilter, event) { + handleFilterRemoveClick = ( + filter: Filter, + containerFilter: Filter | undefined, + event: React.MouseEvent + ) => { event.preventDefault(); if (containerFilter != null) { const otherFilters = this.props.filters.filter( (f) => f !== containerFilter ); + const containerValue = containerFilter.value as MultiFilterValue; const nextContainerFilter = { ...containerFilter, value: { - ...containerFilter.value, - filters: containerFilter.value.filters.filter((f) => f !== filter), + ...containerValue, + filters: containerValue.filters.filter((f: any) => f !== filter), }, - }; + } as Filter; this.props.onFiltersChange( - otherFilters.concat( - shouldAddFilter(nextContainerFilter) ? [nextContainerFilter] : [] - ) + shouldAddFilter(nextContainerFilter, [], false) + ? otherFilters.concat(nextContainerFilter) + : otherFilters ); } else { this.props.onFiltersChange( this.props.filters.filter((f) => f !== filter) ); } - } + }; - renderFilterItem(filter, containerFilter) { - var { fieldTree } = this.props; - var handleSelectClick = partial( + renderFilterItem(filter: Filter, containerFilter?: Filter): JSX.Element { + const { fieldTree } = this.props; + const handleSelectClick = partial( this.handleFilterSelectClick, filter, containerFilter ); - var handleRemoveClick = partial( + const handleRemoveClick = partial( this.handleFilterRemoveClick, filter, containerFilter ); - var field = postorderSeq(fieldTree) + const field = postorderSeq(fieldTree) .map((node) => node.field) .find((field) => field.term === filter.field); - var filterDisplay = getFilterValueDisplay(filter); + const filterDisplay = getFilterValueDisplay(filter); return (
@@ -108,7 +131,7 @@ export default class FilterList extends React.Component { ); } - render() { + render(): JSX.Element { const { activeField, fieldTree, @@ -136,7 +159,9 @@ export default class FilterList extends React.Component { ); - const needsMoreCount = minSelectedCount > filteredDataCount; + const needsMoreCount = minSelectedCount + ? minSelectedCount > (filteredDataCount || 0) + : false; const filtered = hideGlobalCounts ? null : ( field.term === filter.field); return (
  • - {filter.type !== 'multiFilter' ? ( - this.renderFilterItem(filter) - ) : ( + {filter.type === 'multiFilter' ? ( {getOperationDisplay( @@ -193,13 +216,15 @@ export default class FilterList extends React.Component { {field == null ? filter.field : field.display} filters
      - {filter.value.filters.map((leaf) => ( + {filter.value.filters.map((leaf: Filter) => (
    • {this.renderFilterItem(leaf, filter)}
    • ))}
    + ) : ( + this.renderFilterItem(filter) )}
  • ); @@ -210,17 +235,3 @@ export default class FilterList extends React.Component { ); } } - -FilterList.propTypes = { - onActiveFieldChange: PropTypes.func.isRequired, - onFiltersChange: PropTypes.func.isRequired, - fieldTree: PropTypes.object.isRequired, - filters: PropTypes.array.isRequired, - displayName: PropTypes.string.isRequired, - dataCount: PropTypes.number, - filteredDataCount: PropTypes.number, - hideGlobalCounts: PropTypes.bool.isRequired, - loadingFilteredCount: PropTypes.bool, - activeField: PropTypes.object, - minSelectedCount: PropTypes.number, -}; diff --git a/packages/libs/wdk-client/src/Components/AttributeFilter/Histogram.jsx b/packages/libs/wdk-client/src/Components/AttributeFilter/Histogram.tsx similarity index 75% rename from packages/libs/wdk-client/src/Components/AttributeFilter/Histogram.jsx rename to packages/libs/wdk-client/src/Components/AttributeFilter/Histogram.tsx index f42ff49a9a..286371be15 100644 --- a/packages/libs/wdk-client/src/Components/AttributeFilter/Histogram.jsx +++ b/packages/libs/wdk-client/src/Components/AttributeFilter/Histogram.tsx @@ -10,7 +10,6 @@ import { throttle, get, } from 'lodash'; -import PropTypes from 'prop-types'; import React from 'react'; import ReactDOM from 'react-dom'; import { lazy } from '../../Utils/ComponentUtils'; @@ -30,13 +29,54 @@ const DAY = 1000 * 60 * 60 * 24; const IGNORED_UI_STATE_PROPERTIES = ['loading', 'valid', 'errorMessage']; -var distributionEntryPropType = PropTypes.shape({ - value: PropTypes.number.isRequired, - count: PropTypes.number.isRequired, - filteredCount: PropTypes.number.isRequired, -}); +// Type definitions +interface DistributionEntry { + value: number; + count: number; + filteredCount: number; +} + +interface UIState { + xaxisMin?: number; + xaxisMax?: number; + yaxisMin?: number; + yaxisMax?: number; + binSize?: number; + binStart?: number; + scaleYAxis?: boolean; +} + +interface HistogramProps { + distribution: DistributionEntry[]; + selectedMin?: number | null; + selectedMax?: number | null; + chartType: 'number' | 'date'; + timeformat?: string; + xaxisLabel?: string; + yaxisLabel?: string; + truncateYAxis?: boolean; + defaultScaleYAxis?: boolean; + uiState: UIState; + onUiStateChange: (uiState: UIState) => void; + onSelected: (range: { min: number | null; max: number | null }) => void; + onSelecting: (range: { min: number | null; max: number | null }) => void; + onUnselected?: (range: { min: number | null; max: number | null }) => void; +} + +interface HistogramState { + uiState: UIState; + showSettings: boolean; +} + +interface Transform { + display: string; + xform: (v: number) => number; + inverse: (v: number) => number; +} + +type Transforms = Record; -const transforms = { +const transforms: Transforms = { none: { display: 'None', xform: (v) => v, @@ -63,35 +103,42 @@ const PLOT_SETTINGS_OPEN_KEY = 'wdk/filterParam/plotSettingsOpen'; var Histogram = (function () { /** Common histogram component */ - class LazyHistogram extends React.Component { - constructor(props) { + class LazyHistogram extends React.Component { + plot: any; + $chart: any; + tooltip: any; + + constructor(props: HistogramProps) { super(props); - this.handleResize = throttle(this.handleResize.bind(this), 100); - this.emitStateChange = debounce(this.emitStateChange, 100); + this.handleResize = throttle(this.handleResize.bind(this), 100) as any; + this.emitStateChange = debounce(this.emitStateChange, 100) as any; this.state = { uiState: this.getStateFromProps(props), showSettings: sessionStorage.getItem(PLOT_SETTINGS_OPEN_KEY) !== 'false', }; - this.getRange = memoize(this.getRange); - this.getNumFixedDigits = memoize(this.getNumFixedDigits); - this.getDefaultBinSize = memoize(this.getDefaultBinSize); + this.getRange = memoize(this.getRange) as any; + this.getNumFixedDigits = memoize(this.getNumFixedDigits) as any; + this.getDefaultBinSize = memoize(this.getDefaultBinSize) as any; } componentDidMount() { - $(window).on('resize', this.handleResize); - $(ReactDOM.findDOMNode(this)) - .on('plotselected .chart', this.handlePlotSelected.bind(this)) - .on('plotselecting .chart', this.handlePlotSelecting.bind(this)) - .on('plotunselected .chart', this.handlePlotUnselected.bind(this)) - .on('plothover .chart', this.handlePlotHover.bind(this)); + ($ as any)(window).on('resize', this.handleResize); + const node = ReactDOM.findDOMNode(this); + if (node) { + ($(node) as any) + .on('plotselected .chart', this.handlePlotSelected.bind(this)) + .on('plotselecting .chart', this.handlePlotSelecting.bind(this)) + .on('plotunselected .chart', this.handlePlotUnselected.bind(this)) + .on('plothover .chart', this.handlePlotHover.bind(this)); + } this.createPlot(); this.createTooltip(); this.drawPlotSelection(); } - UNSAFE_componentWillReceiveProps(nextProps) { + UNSAFE_componentWillReceiveProps(nextProps: HistogramProps) { if ( nextProps.uiState.yaxisMax !== this.state.uiState.yaxisMax || nextProps.uiState.xaxisMin !== this.state.uiState.xaxisMin || @@ -105,7 +152,7 @@ var Histogram = (function () { /** * Conditionally update plot and selection based on props and state. */ - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps: HistogramProps) { if ( !isEqual(this.props.distribution, prevProps.distribution) || !isEqual( @@ -131,12 +178,12 @@ var Histogram = (function () { if (this.tooltip) this.tooltip.qtip('destroy'); } - isProbablity(props) { + isProbablity(props: HistogramProps) { const { min, max } = this.getRange(props.distribution); return min >= 0 && (max === 1 || max === 100); } - getStateFromProps(props) { + getStateFromProps(props: HistogramProps): UIState { var { xaxisMin, xaxisMax } = this.getXAxisMinMax(props); var binStart = props.uiState.binStart ?? props.chartType === 'date' @@ -151,7 +198,8 @@ var Histogram = (function () { ); var { scaleYAxis = props.defaultScaleYAxis, yaxisMin = 0 } = props.uiState; - xaxisMax = assignBin(binSize, binStart, xaxisMax) + binSize; + const assignedBin = assignBin(binSize ?? 0, binStart, xaxisMax ?? 0); + xaxisMax = (assignedBin ?? 0) + (binSize ?? 0); return { yaxisMax, xaxisMin, @@ -163,7 +211,7 @@ var Histogram = (function () { }; } - getRange(distribution) { + getRange(distribution: DistributionEntry[]) { return distribution.reduce( ({ min, max }, entry) => ({ min: Math.min(min, entry.value), @@ -173,11 +221,11 @@ var Histogram = (function () { ); } - isEveryValueAnInteger(distribution) { + isEveryValueAnInteger(distribution: DistributionEntry[]): boolean { return distribution.every(({ value }) => Number.isInteger(value)); } - getDefaultBinSize(props) { + getDefaultBinSize(props: HistogramProps): number { if (props.chartType === 'date') return 1; const { min, max } = this.getRange(props.distribution); if (this.isProbablity(props)) return max / 100; @@ -194,7 +242,10 @@ var Histogram = (function () { return Math.ceil(binSize); } - getXAxisMinMax(props) { + getXAxisMinMax(props: HistogramProps): { + xaxisMin: number; + xaxisMax: number; + } { const { min, max } = this.getRange(props.distribution); var { xaxisMin, xaxisMax } = props.uiState; if (xaxisMin == null) @@ -203,7 +254,10 @@ var Histogram = (function () { return { xaxisMin, xaxisMax }; } - getYAxisMax(distribution, truncateYAxis) { + getYAxisMax( + distribution: DistributionEntry[], + truncateYAxis: boolean + ): number { var counts = distribution.map((entry) => entry.count); // Reverse sort, then pull out first and second highest values var [max, nextMax] = counts.sort((a, b) => (a < b ? 1 : -1)); @@ -214,24 +268,27 @@ var Histogram = (function () { return yaxisMax; } - getClampedDistribution(distribution, uiState) { + getClampedDistribution( + distribution: DistributionEntry[], + uiState: UIState + ): DistributionEntry[] { const { xaxisMin, xaxisMax } = uiState; return xaxisMin == null && xaxisMax == null ? distribution : xaxisMax == null - ? distribution.filter((entry) => entry.value >= xaxisMin) + ? distribution.filter((entry) => entry.value >= xaxisMin!) : xaxisMin == null - ? distribution.filter((entry) => entry.value <= xaxisMax) + ? distribution.filter((entry) => entry.value <= xaxisMax!) : distribution.filter( - (entry) => entry.value >= xaxisMin && entry.value <= xaxisMax + (entry) => entry.value >= xaxisMin! && entry.value <= xaxisMax! ); } - getBarWidth(distribution) { + getBarWidth(distribution: DistributionEntry[]): number { // padding factor const padding = 0.75; const { xaxisMin, xaxisMax } = this.state.uiState; - const length = xaxisMax - xaxisMin; + const length = xaxisMax! - xaxisMin!; const minWidth = length / 1000; const maxWidth = length / 100; // For dates, use one day as width @@ -248,12 +305,16 @@ var Histogram = (function () { ? minDistance : Math.min(entry.value - prevValue, minDistance), }), - { prev: null, minDistance: Infinity } + { prevValue: null as number | null, minDistance: Infinity } ); return clamp(minDistance * padding, minWidth, maxWidth); } - getBinnedDistribution(binSize, binStart, distribution) { + getBinnedDistribution( + binSize: number, + binStart: number, + distribution: DistributionEntry[] + ): DistributionEntry[] { return createBinnedDistribution( this.props.chartType === 'date' ? binSize * DAY : binSize, this.props.chartType === 'date' ? binStart * DAY : binStart, @@ -261,7 +322,9 @@ var Histogram = (function () { ); } - getSeriesData(distribution) { + getSeriesData( + distribution: DistributionEntry[] + ): Array<{ data: Array<[number, number]>; hoverable?: boolean }> { return [ { data: distribution.map((entry) => [entry.value, entry.count]), @@ -274,7 +337,7 @@ var Histogram = (function () { ]; } - getNumFixedDigits(distribution) { + getNumFixedDigits(distribution: DistributionEntry[]): number { return Seq.from(distribution) .map((entry) => getNumFixedDigits(entry.value)) .reduce(Math.max, 0); @@ -287,12 +350,12 @@ var Histogram = (function () { this.drawPlotSelection(); } - handlePlotSelected(event, ranges) { + handlePlotSelected(event: any, ranges: any) { var range = unwrapXaxisRange(ranges); this.props.onSelected(range); } - handlePlotSelecting(event, ranges) { + handlePlotSelecting(event: any, ranges: any) { if (!ranges) return; var range = unwrapXaxisRange(ranges); this.props.onSelecting(range); @@ -342,12 +405,16 @@ var Histogram = (function () { var { uiState } = this.state; var { binSize, binStart, xaxisMin, xaxisMax } = uiState; const distribution = binSize - ? this.getBinnedDistribution(binSize, binStart, this.props.distribution) + ? this.getBinnedDistribution( + binSize, + binStart ?? 0, + this.props.distribution + ) : this.props.distribution; const clampedDistribution = binSize ? distribution : this.getClampedDistribution(distribution, uiState); - const useScientificNotation = xaxisMax - xaxisMin < 0.1; + const useScientificNotation = xaxisMax! - xaxisMin! < 0.1; var barWidth = binSize ? chartType === 'date' @@ -359,7 +426,7 @@ var Histogram = (function () { chartType === 'date' ? { mode: 'time', timeformat: timeformat } : { - tickFormatter: (value) => + tickFormatter: (value: number) => useScientificNotation ? value.toExponential(2) : value.toLocaleString(undefined, { @@ -391,8 +458,8 @@ var Histogram = (function () { xaxis: Object.assign( { tickLength: 0, - min: binSize ? xaxisMin : xaxisMin - barWidth, - max: binSize ? xaxisMax : xaxisMax + barWidth, + min: binSize ? xaxisMin : xaxisMin! - barWidth, + max: binSize ? xaxisMax : xaxisMax! + barWidth, }, xaxisBaseOptions ), @@ -416,8 +483,11 @@ var Histogram = (function () { if (this.plot) this.plot.destroy(); - this.$chart = $(ReactDOM.findDOMNode(this)).find('.chart'); - this.plot = $.plot(this.$chart, seriesData, plotOptions); + const node = ReactDOM.findDOMNode(this); + if (node) { + this.$chart = ($(node) as any).find('.chart'); + this.plot = ($ as any).plot(this.$chart, seriesData, plotOptions); + } } createTooltip() { @@ -441,7 +511,7 @@ var Histogram = (function () { }); } - handlePlotHover(event, pos, item) { + handlePlotHover(event: any, pos: any, item: any) { var qtipApi = this.tooltip.qtip('api'); if (!item) { @@ -461,12 +531,12 @@ var Histogram = (function () { const barWidthPx = (item.series.bars.barWidth / 2) * pointToPixelFactor; var formattedValue = this.props.chartType === 'date' - ? formatDate(this.props.timeformat, value) + ? formatDate(this.props.timeformat ?? 'yyyy-MM-dd', value) : formatNumber(value, { useScientificNotation: false }); var formattedBinEnd = this.props.chartType === 'date' ? formatDate( - this.props.timeformat, + this.props.timeformat ?? 'yyyy-MM-dd', value + (this.state.uiState.binSize || 0) * DAY ) : formatNumber(value + (this.state.uiState.binSize || 0), { @@ -502,35 +572,42 @@ var Histogram = (function () { } } - updateUIState(updater) { + updateUIState(updater: (uiState: UIState) => UIState) { this.setState( ({ uiState }) => ({ uiState: updater(uiState) }), () => this.emitStateChange(this.state.uiState) ); } - emitStateChange(uiState) { + emitStateChange(uiState: UIState) { this.props.onUiStateChange(uiState); } // x-axis settings // --------------- - setXAxisRange(xaxisMin, xaxisMax) { + setXAxisRange(xaxisMin: number, xaxisMax: number) { this.updateUIState((uiState) => ({ ...uiState, xaxisMin, xaxisMax })); } - setXAxisBinSize(binSize) { + setXAxisBinSize(binSize: number) { this.setXAxisBinState(this.state.uiState.binStart, binSize); } - setXAxisBinStart(binStart) { + setXAxisBinStart(binStart: number) { this.setXAxisBinState(binStart, this.state.uiState.binSize); } - setXAxisBinState(binStart, binSize) { + setXAxisBinState( + binStart: number | undefined, + binSize: number | undefined + ) { const distribution = binSize - ? this.getBinnedDistribution(binSize, binStart, this.props.distribution) + ? this.getBinnedDistribution( + binSize, + binStart || 0, + this.props.distribution + ) : this.props.distribution; const yaxisMax = binSize ? Math.max(...distribution.map((entry) => entry.count)) @@ -552,11 +629,11 @@ var Histogram = (function () { this.props.chartType === 'date' ? xaxisMin : Math.min(0, xaxisMin); const rangeMin = binStart; const rangeMax = - assignBin( + (assignBin( binSize, binStart, xaxisMax === rangeMin ? rangeMin + 1 : xaxisMax - ) + binSize; + ) ?? 0) + binSize; this.setXAxisRange(rangeMin, rangeMax); this.setXAxisBinState(binStart, binSize); } @@ -564,22 +641,22 @@ var Histogram = (function () { // y-axis settings // --------------- - setYAxisRange(yaxisMin, yaxisMax) { + setYAxisRange(yaxisMin: number, yaxisMax: number) { this.updateUIState((uiState) => ({ ...uiState, yaxisMin, yaxisMax })); } - setScaleYAxis(scaleYAxis) { + setScaleYAxis(scaleYAxis: boolean) { this.updateUIState((uiState) => ({ ...uiState, scaleYAxis })); } resetYAxisState() { const { binStart, binSize } = this.state.uiState; this.setXAxisBinState(binStart, binSize); - this.setScaleYAxis(this.props.defaultScaleYAxis); + this.setScaleYAxis(this.props.defaultScaleYAxis || false); this.updateUIState((uiState) => ({ ...uiState, yaxisMin: 0 })); } - setSettingsOpen(showSettings) { + setSettingsOpen(showSettings: boolean) { sessionStorage.setItem( PLOT_SETTINGS_OPEN_KEY, JSON.stringify(Boolean(showSettings)) @@ -615,11 +692,11 @@ var Histogram = (function () { inline hideReset value={{ - min: formatDate(timeformat, xaxisMin), - max: formatDate(timeformat, xaxisMax), + min: formatDate(timeformat ?? 'yyyy-MM-dd', xaxisMin ?? 0), + max: formatDate(timeformat ?? 'yyyy-MM-dd', xaxisMax ?? 0), }} - start={formatDate(timeformat, valuesMin)} - end={formatDate(timeformat, valuesMax)} + start={formatDate(timeformat ?? 'yyyy-MM-dd', valuesMin ?? 0)} + end={formatDate(timeformat ?? 'yyyy-MM-dd', valuesMax ?? 0)} onChange={(value) => this.setXAxisRange( parseDate(value.min).getTime(), @@ -631,8 +708,8 @@ var Histogram = (function () { this.setYAxisRange(Number(value.min), Number(value.max)) } @@ -738,7 +816,9 @@ var Histogram = (function () { min={0} value={this.state.uiState.binSize} onFocus={autoSelectOnFocus} - onChange={(e) => this.setXAxisBinSize(eventToNumber(e))} + onChange={(e) => + this.setXAxisBinSize(eventToNumber(e) ?? 0) + } /> {' '} @@ -778,39 +858,7 @@ var Histogram = (function () { } } - LazyHistogram.propTypes = { - distribution: PropTypes.arrayOf(distributionEntryPropType).isRequired, - selectedMin: PropTypes.number, - selectedMax: PropTypes.number, - chartType: PropTypes.oneOf(['number', 'date']).isRequired, - timeformat: PropTypes.string, - xaxisLabel: PropTypes.string, - yaxisLabel: PropTypes.string, - - // Controls if truncation logic is applied - // to deemphasize outliers - truncateYAxis: PropTypes.bool, - - defaultScaleYAxis: PropTypes.bool, - - uiState: PropTypes.shape({ - xaxisMin: PropTypes.number, - xaxisMax: PropTypes.number, - yaxisMin: PropTypes.number, - yaxisMax: PropTypes.number, - binSize: PropTypes.number, - binStart: PropTypes.number, - scaleYAxis: PropTypes.bool, - }), - - onUiStateChange: PropTypes.func, - - onSelected: PropTypes.func, - onSelecting: PropTypes.func, - onUnselected: PropTypes.func, - }; - - LazyHistogram.defaultProps = { + (LazyHistogram as any).defaultProps = { xaxisLabel: 'X-Axis', yaxisLabel: 'Y-Axis', selectedMin: null, @@ -821,11 +869,15 @@ var Histogram = (function () { onUnselected: noop, }; - return lazy(async () => { + return lazy(async () => { + // @ts-ignore - vendored script-loader imports await import('!!script-loader!../../../vendored/flot/jquery.flot'); await Promise.all([ + // @ts-ignore - vendored script-loader imports import('!!script-loader!../../../vendored/flot/jquery.flot.categories'), + // @ts-ignore - vendored script-loader imports import('!!script-loader!../../../vendored/flot/jquery.flot.selection'), + // @ts-ignore - vendored script-loader imports import('!!script-loader!../../../vendored/flot/jquery.flot.time'), ]); })(LazyHistogram); @@ -833,8 +885,21 @@ var Histogram = (function () { export default Histogram; -function RangeWarning({ rangeMin, rangeMax, selectionMin, selectionMax }) { - if (rangeMin >= selectionMin && rangeMax <= selectionMax) return null; +interface RangeWarningProps { + rangeMin: number; + rangeMax: number; + selectionMin?: number; + selectionMax?: number; +} + +function RangeWarning({ + rangeMin, + rangeMax, + selectionMin, + selectionMax, +}: RangeWarningProps) { + if (rangeMin >= (selectionMin ?? 0) && rangeMax <= (selectionMax ?? 0)) + return null; return ( @@ -846,7 +911,14 @@ function RangeWarning({ rangeMin, rangeMax, selectionMin, selectionMax }) { * Reusable histogram field component. The parent component is responsible for * preparing the data. */ -function unwrapXaxisRange(flotRanges) { +interface FlotRanges { + xaxis: { from: number; to: number }; +} + +function unwrapXaxisRange(flotRanges: FlotRanges | null | undefined): { + min: number | null; + max: number | null; +} { if (flotRanges == null) { return { min: null, max: null }; } @@ -856,7 +928,7 @@ function unwrapXaxisRange(flotRanges) { } const FIXED_DIGITS_RE = /^(\d*)\.?(\d*)$/; -function getNumFixedDigits(num) { +function getNumFixedDigits(num: number): number { const matches = FIXED_DIGITS_RE.exec(String(num)); return matches == null ? 0 : matches[2].length; } @@ -870,7 +942,11 @@ function getNumFixedDigits(num) { // - Bar width will be determined by binSize. // -function assignBin(binSize, binStart, value) { +function assignBin( + binSize: number, + binStart: number, + value: number +): number | undefined { if (value < binStart) return; const shift = binStart % binSize; const shiftedValue = value - shift; @@ -879,26 +955,38 @@ function assignBin(binSize, binStart, value) { return bin + shift; } -function createBinnedDistribution(binSize, binStart, distribution) { - const binnedObject = distribution.reduce((binnedDist, entry) => { - const bin = assignBin(binSize, binStart, entry.value); - if (bin == null) return binnedDist; - const count = get(binnedDist, [bin, 'count'], 0) + entry.count; - const filteredCount = - get(binnedDist, [bin, 'filteredCount'], 0) + entry.filteredCount; - return Object.assign(binnedDist, { [bin]: { count, filteredCount } }); - }, {}); +function createBinnedDistribution( + binSize: number, + binStart: number, + distribution: DistributionEntry[] +): DistributionEntry[] { + const binnedObject = distribution.reduce( + ( + binnedDist: Record, + entry + ) => { + const bin = assignBin(binSize, binStart, entry.value); + if (bin == null) return binnedDist; + const count = get(binnedDist, [bin, 'count'], 0) + entry.count; + const filteredCount = + get(binnedDist, [bin, 'filteredCount'], 0) + entry.filteredCount; + return Object.assign(binnedDist, { [bin]: { count, filteredCount } }); + }, + {} + ); return Object.entries(binnedObject).map((entry) => ({ value: Number(entry[0]), ...entry[1], })); } -function eventToNumber(event) { +function eventToNumber( + event: React.ChangeEvent +): number | undefined { const value = event.target.value.trim(); return value === '' ? undefined : Number(value); } -function autoSelectOnFocus(event) { +function autoSelectOnFocus(event: React.FocusEvent) { event.target.select(); } diff --git a/packages/libs/wdk-client/src/Components/AttributeFilter/HistogramField.jsx b/packages/libs/wdk-client/src/Components/AttributeFilter/HistogramField.tsx similarity index 65% rename from packages/libs/wdk-client/src/Components/AttributeFilter/HistogramField.jsx rename to packages/libs/wdk-client/src/Components/AttributeFilter/HistogramField.tsx index ff2f88acf8..d13758b083 100644 --- a/packages/libs/wdk-client/src/Components/AttributeFilter/HistogramField.jsx +++ b/packages/libs/wdk-client/src/Components/AttributeFilter/HistogramField.tsx @@ -1,10 +1,70 @@ import React from 'react'; -import PropTypes from 'prop-types'; -import { clamp, debounce, get } from 'lodash'; +import { clamp, debounce, get, noop } from 'lodash'; import Histogram from '../../Components/AttributeFilter/Histogram'; import FilterLegend from '../../Components/AttributeFilter/FilterLegend'; import UnknownCount from '../../Components/AttributeFilter/UnknownCount'; +import { + Field, + RangeFilter, + OntologyTermSummary, + ValueCounts, +} from '../../Components/AttributeFilter/Types'; + +/** + * Distribution entry type for histogram data + */ +interface DistributionEntry { + value: number | string | null; + count: number; + filteredCount: number; +} + +/** + * Range value type for min/max values + */ +interface RangeValue { + min?: number | string | null; + max?: number | string | null; +} + +/** + * Props for the HistogramField component + */ +interface HistogramFieldProps { + distribution: DistributionEntry[]; + toFilterValue: (value: number) => number | string; + toHistogramValue: (value: number | string) => number; + selectByDefault: boolean; + onChange: ( + activeField: Field, + range: RangeValue, + includeUnknown: boolean, + valueCounts: ValueCounts + ) => void; + activeField: Field; + activeFieldState: { + summary: OntologyTermSummary; + [key: string]: any; + }; + filter?: RangeFilter; + overview: React.ReactNode; + displayName: string; + unknownCount: number; + timeformat?: string; + onRangeScaleChange?: (activeField: Field, range: any) => void; + histogramTruncateYAxisDefault?: boolean; + histogramScaleYAxisDefault?: boolean; +} + +/** + * State for the HistogramField component + */ +interface HistogramFieldState { + includeUnknown: boolean; + minInputValue: number | string | null; + maxInputValue: number | string | null; +} /** * Generic Histogram field component @@ -13,8 +73,20 @@ import UnknownCount from '../../Components/AttributeFilter/UnknownCount'; * TODO Use bin size for x-axis scale step attribute * TODO Interval snapping */ -export default class HistogramField extends React.Component { - static getHelpContent(props) { +export default class HistogramField extends React.Component< + HistogramFieldProps, + HistogramFieldState +> { + convertedDistribution: DistributionEntry[] = []; + convertedDistributionRange: { min: number; max: number } = { + min: 0, + max: 0, + }; + distributionRange: RangeValue = { min: 0, max: 0 }; + + updateFilterValueFromSelection!: (range: RangeValue) => void; + + static getHelpContent(props: HistogramFieldProps) { return (
    Select a range of {props.activeField.display} values with the graph @@ -37,10 +109,10 @@ export default class HistogramField extends React.Component { */ } - constructor(props) { + constructor(props: HistogramFieldProps) { super(props); this.updateFilterValueFromSelection = debounce( - this.updateFilterValueFromSelection.bind(this), + this._updateFilterValueFromSelection.bind(this), 50 ); this.handleMinInputBlur = this.handleMinInputBlur.bind(this); @@ -61,7 +133,7 @@ export default class HistogramField extends React.Component { }; } - UNSAFE_componentWillReceiveProps(nextProps) { + UNSAFE_componentWillReceiveProps(nextProps: HistogramFieldProps) { let distributionChanged = this.props.distribution !== nextProps.distribution; let filterChanged = this.props.filter !== nextProps.filter; @@ -77,11 +149,15 @@ export default class HistogramField extends React.Component { } } - cacheDistributionOperations(props) { + cacheDistributionOperations(props: HistogramFieldProps) { this.convertedDistribution = props.distribution.map((entry) => - Object.assign({}, entry, { value: props.toHistogramValue(entry.value) }) + Object.assign({}, entry, { + value: entry.value != null ? props.toHistogramValue(entry.value) : 0, + }) ); - var values = this.convertedDistribution.map((entry) => entry.value); + var values = this.convertedDistribution.map( + (entry) => entry.value + ) as number[]; var min = Math.min(...values); var max = Math.max(...values); this.convertedDistributionRange = { min, max }; @@ -91,7 +167,7 @@ export default class HistogramField extends React.Component { }; } - formatRangeValue(value) { + formatRangeValue(value: number | string | null): number | string | null { const { min, max } = this.convertedDistributionRange; return value ? this.props.toFilterValue( @@ -100,7 +176,7 @@ export default class HistogramField extends React.Component { : null; } - handleMinInputChange(event) { + handleMinInputChange(event: React.ChangeEvent) { this.setState({ minInputValue: event.target.value }); } @@ -108,11 +184,11 @@ export default class HistogramField extends React.Component { this.updateFilterValueFromState(); } - handleMinInputKeyPress(event) { + handleMinInputKeyPress(event: React.KeyboardEvent) { if (event.key === 'Enter') this.updateFilterValueFromState(); } - handleMaxInputChange(event) { + handleMaxInputChange(event: React.ChangeEvent) { this.setState({ maxInputValue: event.target.value }); } @@ -120,11 +196,11 @@ export default class HistogramField extends React.Component { this.updateFilterValueFromState(); } - handleMaxInputKeyPress(event) { + handleMaxInputKeyPress(event: React.KeyboardEvent) { if (event.key === 'Enter') this.updateFilterValueFromState(); } - handleRangeScaleChange(range) { + handleRangeScaleChange(range: any) { if (this.props.onRangeScaleChange != null) { this.props.onRangeScaleChange(this.props.activeField, range); } @@ -136,35 +212,34 @@ export default class HistogramField extends React.Component { this.updateFilterValue({ min, max }); } - updateFilterValueFromSelection(range) { - const min = this.formatRangeValue(range.min); - const max = this.formatRangeValue(range.max); + _updateFilterValueFromSelection(range: RangeValue) { + const min = this.formatRangeValue(range.min ?? null); + const max = this.formatRangeValue(range.max ?? null); // XXX Snap to actual values? this.updateFilterValue({ min, max }); } - updateFilterValue(range) { + updateFilterValue(range: RangeValue) { // only emit change if range differs from filter if (this.rangeIsDifferent(range)) this.emitChange(range); } - /** - * @param {React.ChangeEvent.} event - */ - handleUnknownCheckboxChange(event) { + handleUnknownCheckboxChange(event: React.ChangeEvent) { const includeUnknown = event.target.checked; this.setState({ includeUnknown }); this.emitChange(get(this.props, 'filter.value'), includeUnknown); } - rangeIsDifferent({ min, max }) { - if (this.props.filter == null) return min != null || max != null; + rangeIsDifferent(range: RangeValue): boolean { + if (this.props.filter == null) + return range.min != null || range.max != null; return ( - min !== this.props.filter.value.min || max !== this.props.filter.value.max + range.min !== this.props.filter.value.min || + range.max !== this.props.filter.value.max ); } - emitChange(range, includeUnknown = this.state.includeUnknown) { + emitChange(range: any, includeUnknown: boolean = this.state.includeUnknown) { this.props.onChange( this.props.activeField, range, @@ -216,7 +291,10 @@ export default class HistogramField extends React.Component { var selectedMin = min == null ? null : this.props.toHistogramValue(min); var selectedMax = max == null ? null : this.props.toHistogramValue(max); - var selectionTotal = filter && filter.selection && filter.selection.length; + var selectionTotal = + filter && + (filter as any).selection && + ((filter as any).selection as any).length; var selection = selectionTotal != null ? ' (' + selectionTotal + ' selected) ' : null; @@ -231,7 +309,7 @@ export default class HistogramField extends React.Component { {'Select ' + activeField.display + ' from '}
    - +
      {map(filters, (filter) => ( -
    • +
    • {JSON.stringify(pick(filter, 'field', 'value', 'includeUnknown'))}
    • ))} @@ -25,7 +30,3 @@ export default function InvalidFilterList(props) {

    ); } - -InvalidFilterList.propTypes = { - filters: PropTypes.array, -}; diff --git a/packages/libs/wdk-client/src/Components/AttributeFilter/MembershipField.jsx b/packages/libs/wdk-client/src/Components/AttributeFilter/MembershipField.tsx similarity index 57% rename from packages/libs/wdk-client/src/Components/AttributeFilter/MembershipField.jsx rename to packages/libs/wdk-client/src/Components/AttributeFilter/MembershipField.tsx index a7dc6bf923..34a0b99348 100644 --- a/packages/libs/wdk-client/src/Components/AttributeFilter/MembershipField.jsx +++ b/packages/libs/wdk-client/src/Components/AttributeFilter/MembershipField.tsx @@ -19,26 +19,112 @@ import { findAncestorNode } from '../../Utils/DomUtils'; import StackedBar from '../../Components/AttributeFilter/StackedBar'; import UnknownCount from '../../Components/AttributeFilter/UnknownCount'; import { toPercentage } from '../../Components/AttributeFilter/AttributeFilterUtils'; +import { + Filter, + OntologyTermSummary, + MemberField, + ValueCounts, +} from '../../Components/AttributeFilter/Types'; const UNKNOWN_ELEMENT = Not specified; -class MembershipField extends React.PureComponent { - constructor(props) { +// Type definitions for MembershipField component +interface MembershipFieldState { + showDisabledTooltip: boolean; + tooltipTop?: number; + tooltipLeft?: number; + top?: undefined; + left?: undefined; +} + +interface MembershipFieldProps { + activeField: MemberField; + activeFieldState: { + sort?: { + groupBySelected: boolean; + [key: string]: any; + }; + summary: OntologyTermSummary; + }; + filter?: Filter | null; + onMemberSort?: (field: MemberField, sort: any) => void; + displayName: string; + filteredCountHeadingPrefix?: string; + unfilteredCountHeadingPrefix?: string; + showInternalMesaCounts?: boolean; + dataCount: number; + fillBarColor?: string; + fillFilteredBarColor?: string; + onChange: ( + activeField: MemberField, + value: any, + includeUnknown: boolean, + rows: ValueCounts + ) => void; +} + +interface MembershipTableState { + // MembershipTable doesn't have state, but we need this for class definition +} + +interface MembershipTableProps extends MembershipFieldProps { + activeFieldState: { + sort?: { + groupBySelected: boolean; + [key: string]: any; + }; + summary: OntologyTermSummary; + currentPage?: number; + rowsPerPage?: number; + searchTerm?: string; + [key: string]: any; + }; + selectByDefault?: boolean; + onMemberSearch?: ( + field: MemberField, + searchTerm: string, + shouldResetPaging?: boolean + ) => void; + onMemberChangeCurrentPage?: (field: MemberField, page: number) => void; + onMemberChangeRowsPerPage?: (field: MemberField, rowsPerPage: number) => void; +} + +interface ValueCountItem { + value: string | number | null; + count: number; + filteredCount: number; +} + +interface SortEvent { + key: string; +} + +class MembershipField extends React.PureComponent< + MembershipTableProps, + MembershipFieldState +> { + debouncedMapMouseTargetToTooltipState: any; + + constructor(props: MembershipTableProps) { super(props); - this.state = {}; + this.state = { showDisabledTooltip: false }; this.handleMouseOver = this.handleMouseOver.bind(this); this.handleMouseLeave = this.handleMouseLeave.bind(this); this.handleGroupBySelected = this.handleGroupBySelected.bind(this); - this.mapMouseTargetToTooltipState = debounce( - this.mapMouseTargetToTooltipState, + this.debouncedMapMouseTargetToTooltipState = debounce( + this.mapMouseTargetToTooltipState.bind(this), 250 ); - this.state = { showDisabledTooltip: false }; } - handleMouseOver(event) { + handleMouseOver( + event: React.MouseEvent & { + target: HTMLElement; + originalTarget?: HTMLElement; + } + ) { const { target, originalTarget } = event; - this.mapMouseTargetToTooltipState(target, originalTarget); + this.debouncedMapMouseTargetToTooltipState(target, originalTarget); } handleMouseLeave() { @@ -47,16 +133,18 @@ class MembershipField extends React.PureComponent { top: undefined, left: undefined, }); - this.mapMouseTargetToTooltipState.cancel(); + this.debouncedMapMouseTargetToTooltipState.cancel(); } handleGroupBySelected() { - this.props.onMemberSort( - this.props.activeField, - Object.assign({}, this.props.activeFieldState.sort, { - groupBySelected: !this.props.activeFieldState.sort.groupBySelected, - }) - ); + if (this.props.onMemberSort && this.props.activeFieldState.sort) { + this.props.onMemberSort( + this.props.activeField, + Object.assign({}, this.props.activeFieldState.sort, { + groupBySelected: !this.props.activeFieldState.sort.groupBySelected, + }) + ); + } } isSortEnabled() { @@ -66,11 +154,19 @@ class MembershipField extends React.PureComponent { ); } - mapMouseTargetToTooltipState(element, root) { - const disabledRow = findAncestorNode(element, isDisabledRow, root); + mapMouseTargetToTooltipState(element: HTMLElement, root?: HTMLElement) { + const disabledRow = findAncestorNode( + element, + (node: Node) => isDisabledRow(node as HTMLElement), + root + ); const showDisabledTooltip = disabledRow != null; - const { top, left } = - disabledRow == null ? {} : disabledRow.getBoundingClientRect(); + const rect = + disabledRow == null + ? null + : (disabledRow as HTMLElement).getBoundingClientRect(); + const top = rect?.top ?? 0; + const left = rect?.left ?? 0; this.setState({ showDisabledTooltip, @@ -109,7 +205,9 @@ class MembershipField extends React.PureComponent { onClick={this.handleGroupBySelected} > {' '} Keep checked values at top @@ -140,29 +238,37 @@ class MembershipField extends React.PureComponent { } } -MembershipField.defaultProps = { - filteredCountHeadingPrefix: 'Remaining', - unfilteredCountHeadingPrefix: '', - showInternalMesaCounts: false, -}; - -function filterBySearchTerm(rows, searchTerm) { +function filterBySearchTerm( + rows: ValueCounts, + searchTerm: string +): ValueCounts { if (searchTerm !== '') { let re = new RegExp(escapeRegExp(searchTerm), 'i'); - return rows.filter((entry) => re.test(entry.value)); + return rows.filter((entry) => re.test(String(entry.value))); } else { return rows; } } -function selectPage(rows, currentPage, rowsPerPage) { + +function selectPage( + rows: ValueCounts, + currentPage: number, + rowsPerPage: number +): ValueCounts { return rows.slice((currentPage - 1) * rowsPerPage, currentPage * rowsPerPage); } /** * Membership activeField component */ -class MembershipTable extends React.PureComponent { - static getHelpContent(props) { +class MembershipTable extends React.PureComponent< + MembershipTableProps, + MembershipTableState +> { + memoizedGetKnownValues: any; + memoizedIsItemSelected: any; + + static getHelpContent(props: MembershipTableProps) { var displayName = props.displayName; var fieldDisplay = props.activeField.display; return ( @@ -174,7 +280,7 @@ class MembershipTable extends React.PureComponent { ); } - constructor(props) { + constructor(props: MembershipTableProps) { super(props); bindAll( this, @@ -187,7 +293,7 @@ class MembershipTable extends React.PureComponent { 'handleSort', 'handleChangeCurrentPage', 'handleChangeRowsPerPage', - 'isItemSelected', + 'isItemSelectedImpl', 'renderCheckboxCell', 'renderCheckboxHeading', 'renderDistributionCell', @@ -204,46 +310,50 @@ class MembershipTable extends React.PureComponent { 'toFilterValue', 'getRows' ); - this.getKnownValues = memoize(this.getKnownValues); - this.isItemSelected = memoize(this.isItemSelected); + this.memoizedGetKnownValues = memoize(this.getKnownValuesImpl.bind(this)); + this.memoizedIsItemSelected = memoize(this.isItemSelectedImpl.bind(this)); } - componentWillReceiveProps(nextProps) { + componentWillReceiveProps(nextProps: MembershipTableProps) { if ( this.props.activeFieldState.summary !== nextProps.activeFieldState.summary ) { - this.getKnownValues.cache.clear(); + this.memoizedGetKnownValues.cache.clear(); } if (this.props.filter !== nextProps.filter) { - this.isItemSelected.cache.clear(); + this.memoizedIsItemSelected.cache.clear(); } } - toFilterValue(value) { + toFilterValue(value: any) { return this.props.activeField.type === 'string' ? String(value) : this.props.activeField.type === 'number' ? Number(value) : this.props.activeField.type === 'date' - ? Date(value) + ? new Date(value) : value; } - getRows() { + getRows(): ValueCounts { return this.props.activeFieldState.summary.valueCounts; } - getKnownValues() { + getKnownValuesImpl() { return this.getRows() .filter(({ value }) => value != null) .map(({ value }) => value); } + getKnownValues() { + return this.memoizedGetKnownValues(); + } + getValuesForFilter() { return get(this.props, 'filter.value'); } - deriveRowClassName(item) { + deriveRowClassName(item: ValueCountItem): string { const selectedClassName = item.filteredCount > 0 && (this.props.filter == null || this.isItemSelected(item)) @@ -256,26 +366,32 @@ class MembershipTable extends React.PureComponent { return `member ${selectedClassName} ${disabledClassName}`; } - isItemSelected(item) { - let { filter, selectByDefault } = this.props; + isItemSelectedImpl(item: ValueCountItem): boolean { + let { filter, selectByDefault = false } = this.props; return filter == null - ? selectByDefault + ? selectByDefault ?? false : // value is null (ie, unknown) and includeUnknown selected item.value == null ? filter.includeUnknown : // filter.value is null (ie, all known values), or filter.value includes value - filter.value == null || filter.value.includes(item.value); + filter.value == null || + (Array.isArray(filter.value) && + (filter.value as Array).includes(item.value)); } - isSortEnabled() { + isItemSelected(item: ValueCountItem): boolean { + return this.memoizedIsItemSelected(item); + } + + isSortEnabled(): boolean { return ( has(this.props, 'activeFieldState.sort') && isFunction(this.props.onMemberSort) ); } - isPaginationEnabled() { + isPaginationEnabled(): boolean { return ( this.getRows().length > 100 && has(this.props, 'activeFieldState.currentPage') && @@ -283,7 +399,7 @@ class MembershipTable extends React.PureComponent { ); } - isSearchEnabled() { + isSearchEnabled(): boolean { return ( this.getRows().length > 10 && has(this.props, 'activeFieldState.searchTerm') && @@ -291,9 +407,10 @@ class MembershipTable extends React.PureComponent { ); } - handleItemClick(item, addItem = !this.isItemSelected(item)) { + handleItemClick(item: ValueCountItem, addItem?: boolean) { let { selectByDefault } = this.props; let { value, filteredCount } = item; + const shouldAdd = addItem ?? !this.isItemSelected(item); if (filteredCount === 0) { // Don't do anything since item is "disabled" @@ -301,7 +418,7 @@ class MembershipTable extends React.PureComponent { } if (value == null) { - this.handleUnknownChange(addItem); + this.handleUnknownChange(shouldAdd); } else { const currentFilterValue = this.props.filter == null @@ -309,9 +426,9 @@ class MembershipTable extends React.PureComponent { ? this.getKnownValues() : [] : this.getValuesForFilter() || this.getKnownValues(); - const filterValue = addItem + const filterValue = shouldAdd ? currentFilterValue.concat(value) - : currentFilterValue.filter((v) => v !== value); + : currentFilterValue.filter((v: string | number | null) => v !== value); this.setSelections( filterValue.length === this.getKnownValues().length @@ -321,19 +438,19 @@ class MembershipTable extends React.PureComponent { } } - handleRowClick(item) { + handleRowClick(item: ValueCountItem) { this.handleItemClick(item); } - handleRowSelect(item) { + handleRowSelect(item: ValueCountItem) { this.handleItemClick(item, true); } - handleRowDeselect(item) { + handleRowDeselect(item: ValueCountItem) { this.handleItemClick(item, false); } - handleUnknownChange(addUnknown) { + handleUnknownChange(addUnknown: boolean) { this.setSelections(this.getValuesForFilter() ?? [], addUnknown); } @@ -367,7 +484,7 @@ class MembershipTable extends React.PureComponent { // Values in the search results that are selectable const selectableResultValues = filterBySearchTerm( selectableRows, - searchTerm + searchTerm as string ).map((row) => row.value); const [selectedResultValues, unselectedResultValues] = partition( selectableResultValues, @@ -384,50 +501,60 @@ class MembershipTable extends React.PureComponent { } } - handleSort({ key: columnKey }, direction) { + handleSort({ key: columnKey }: SortEvent, direction: string) { let nextSort = { columnKey, direction }; let sort = Object.assign({}, this.props.activeFieldState.sort, nextSort); - this.props.onMemberSort(this.props.activeField, sort); + if (this.props.onMemberSort) { + this.props.onMemberSort(this.props.activeField, sort); + } } - handleSearchTermChange(searchTerm) { + handleSearchTermChange(searchTerm: string) { // When we are not on page 1, we need to determine if our currentPage position remains viable // or if it should get reset to page 1 (see note in TableFilter.tsx's handleSearch callback definition) - if (this.props.activeFieldState.currentPage !== 1) { + if ((this.props.activeFieldState.currentPage ?? 1) !== 1) { const numberOfFilteredRows = filterBySearchTerm( this.getRows(), searchTerm ).length; const shouldResetPaging = numberOfFilteredRows <= - this.props.activeFieldState.rowsPerPage * - (this.props.activeFieldState.currentPage - 1); - this.props.onMemberSearch( - this.props.activeField, - searchTerm, - shouldResetPaging - ); + (this.props.activeFieldState.rowsPerPage ?? 50) * + ((this.props.activeFieldState.currentPage ?? 1) - 1); + if (this.props.onMemberSearch) { + this.props.onMemberSearch( + this.props.activeField, + searchTerm, + shouldResetPaging + ); + } + } + if (this.props.onMemberSearch) { + this.props.onMemberSearch(this.props.activeField, searchTerm); } - this.props.onMemberSearch(this.props.activeField, searchTerm); } - handleChangeCurrentPage(newCurrentPage) { - this.props.onMemberChangeCurrentPage( - this.props.activeField, - newCurrentPage - ); + handleChangeCurrentPage(newCurrentPage: number) { + if (this.props.onMemberChangeCurrentPage) { + this.props.onMemberChangeCurrentPage( + this.props.activeField, + newCurrentPage + ); + } } - handleChangeRowsPerPage(newRowsPerPage) { - this.props.onMemberChangeRowsPerPage( - this.props.activeField, - newRowsPerPage - ); + handleChangeRowsPerPage(newRowsPerPage: number) { + if (this.props.onMemberChangeRowsPerPage) { + this.props.onMemberChangeRowsPerPage( + this.props.activeField, + newRowsPerPage + ); + } } setSelections( - value, - includeUnknown = get(this.props, 'filter.includeUnknown', false) + value: any, + includeUnknown: boolean = get(this.props, 'filter.includeUnknown', false) ) { this.props.onChange( this.props.activeField, @@ -457,13 +584,15 @@ class MembershipTable extends React.PureComponent { type="checkbox" disabled={availableItems.length === 0} checked={showChecked} - ref={(el) => el && (el.indeterminate = showIndeterminate)} + ref={(el: HTMLInputElement | null) => + el && (el.indeterminate = showIndeterminate) + } onChange={this.handleSelectAll} /> ); } - renderCheckboxCell({ row }) { + renderCheckboxCell({ row }: { row: ValueCountItem }) { const isChecked = this.isItemSelected(row); const isDisabled = row.filteredCount === 0; const onClick = () => @@ -495,7 +624,7 @@ class MembershipTable extends React.PureComponent { }} > @@ -503,13 +632,13 @@ class MembershipTable extends React.PureComponent { ); } - renderValueCell({ value }) { + renderValueCell({ value }: { value: string | number | null }) { return (
    {value == null ? UNKNOWN_ELEMENT : safeHtml(String(value))}
    ); } - renderCountHeading1(qualifier) { + renderCountHeading1(qualifier: string) { return (
    {internalsCount.toLocaleString()} @@ -543,7 +672,7 @@ class MembershipTable extends React.PureComponent { ); } - renderCountCell(value, internalsCount) { + renderCountCell(value: number, internalsCount: number | null | undefined) { return (
    {value.toLocaleString()} @@ -568,7 +697,9 @@ class MembershipTable extends React.PureComponent { } renderFilteredCountHeading1() { - return this.renderCountHeading1(this.props.filteredCountHeadingPrefix); + return this.renderCountHeading1( + this.props.filteredCountHeadingPrefix ?? 'Remaining' + ); } renderFilteredCountHeading2() { @@ -577,7 +708,7 @@ class MembershipTable extends React.PureComponent { ); } - renderFilteredCountCell({ value }) { + renderFilteredCountCell({ value }: { value: number }) { return this.renderCountCell( value, this.props.activeFieldState.summary.internalsFilteredCount @@ -585,7 +716,9 @@ class MembershipTable extends React.PureComponent { } renderUnfilteredCountHeading1() { - return this.renderCountHeading1(this.props.unfilteredCountHeadingPrefix); + return this.renderCountHeading1( + this.props.unfilteredCountHeadingPrefix ?? '' + ); } renderUnfilteredCountHeading2() { @@ -594,14 +727,14 @@ class MembershipTable extends React.PureComponent { ); } - renderUnfilteredCountCell({ value }) { + renderUnfilteredCountCell({ value }: { value: number }) { return this.renderCountCell( value, this.props.activeFieldState.summary.internalsCount ); } - renderDistributionCell({ row }) { + renderDistributionCell({ row }: { row: ValueCountItem }) { return ( ({Math.round((row.filteredCount / row.count) * 100)}%) @@ -628,8 +761,14 @@ class MembershipTable extends React.PureComponent { var useSort = this.isSortEnabled(); var useSearch = this.isSearchEnabled(); var usePagination = this.isPaginationEnabled(); - const { currentPage, rowsPerPage, searchTerm, ...uiStateOther } = - this.props.activeFieldState; + const { + currentPage = 1, + rowsPerPage = 50, + searchTerm = '', + sort, + summary, + ...uiStateOther + } = this.props.activeFieldState; const rows = this.getRows(); let filteredRows = this.getRows(); @@ -647,7 +786,7 @@ class MembershipTable extends React.PureComponent { const uiState = Object.assign( {}, uiStateOther, - useSearch && searchTerm ? { searchTerm } : {}, + useSearch && searchTerm ? { searchQuery: searchTerm } : {}, usePagination ? { pagination: { @@ -680,7 +819,6 @@ class MembershipTable extends React.PureComponent { options={{ isRowSelected: this.isItemSelected, deriveRowClassName: this.deriveRowClassName, - onRowClick: this.handleRowClick, useStickyHeader: true, tableBodyMaxHeight: '80vh', }} @@ -690,9 +828,7 @@ class MembershipTable extends React.PureComponent { ? [ { selectionRequired: false, - element() { - return null; - }, + element: null, callback: () => null, }, ] @@ -701,98 +837,113 @@ class MembershipTable extends React.PureComponent { eventHandlers={eventHandlers} rows={rows} filteredRows={filteredRows} - columns={[ - { - key: 'checked', - sortable: false, - width: '32px', - renderHeading: this.renderCheckboxHeading, - renderCell: this.renderCheckboxCell, - }, - { - key: 'value', - headingStyle: { minWidth: '12em' }, - inline: true, - sortable: useSort, - wrapCustomHeadings: ({ headingRowIndex }) => headingRowIndex === 0, - renderHeading: useSearch - ? [this.renderValueHeading, this.renderValueHeadingSearch] - : this.renderValueHeading, - renderCell: this.renderValueCell, - }, - { - key: 'filteredCount', - sortable: useSort, - headingStyle: { maxWidth: '12em' }, - helpText: ( -
    - The number of {this.props.displayName} that match the - filters applied for other variables -
    - and have the given - {this.props.activeField.display} - {' '} - value -
    - ), - wrapCustomHeadings: ({ headingRowIndex }) => headingRowIndex === 0, - renderHeading: - this.props.activeFieldState.summary.internalsFilteredCount != null - ? [ - this.renderFilteredCountHeading1, - this.renderFilteredCountHeading2, - ] - : this.renderFilteredCountHeading1, - renderCell: this.renderFilteredCountCell, - }, - { - key: 'count', - sortable: useSort, - headingStyle: { maxWidth: '12em' }, - helpText: ( -
    - The number of {this.props.displayName} in the dataset - that have the given {this.props.activeField.display}{' '} - value -
    - ), - wrapCustomHeadings: ({ headingRowIndex }) => headingRowIndex === 0, - renderHeading: - this.props.activeFieldState.summary.internalsCount != null - ? [ - this.renderUnfilteredCountHeading1, - this.renderUnfilteredCountHeading2, - ] - : this.renderUnfilteredCountHeading1, - renderCell: this.renderUnfilteredCountCell, - }, - { - key: 'distribution', - name: 'Distribution', - width: '30%', - helpText: ( -
    - The subset of {this.props.displayName} that have the - given {this.props.activeField.display} value when other - filters have been applied -
    - ), - renderCell: this.renderDistributionCell, - }, - { - key: '%', - name: '', - width: '4em', - helpText: ( -
    - The subset of {this.props.displayName} out of all{' '} - {this.props.displayName} that have the given{' '} - {this.props.activeField.display} value -
    - ), - renderCell: this.renderPrecentageCell, - }, - ]} + columns={ + [ + { + key: 'checked', + sortable: false, + width: '32px', + renderHeading: this.renderCheckboxHeading, + renderCell: this.renderCheckboxCell, + }, + { + key: 'value', + headingStyle: { minWidth: '12em' }, + inline: true, + sortable: useSort, + wrapCustomHeadings: ({ + headerRowIndex, + }: { + headerRowIndex: number; + }) => headerRowIndex === 0, + renderHeading: useSearch + ? [this.renderValueHeading, this.renderValueHeadingSearch] + : this.renderValueHeading, + renderCell: this.renderValueCell, + }, + { + key: 'filteredCount', + sortable: useSort, + headingStyle: { maxWidth: '12em' }, + helpText: ( +
    + The number of {this.props.displayName} that match the + filters applied for other variables +
    + and have the given + {this.props.activeField.display} + {' '} + value +
    + ), + wrapCustomHeadings: ({ + headerRowIndex, + }: { + headerRowIndex: number; + }) => headerRowIndex === 0, + renderHeading: + this.props.activeFieldState.summary.internalsFilteredCount != + null + ? [ + this.renderFilteredCountHeading1, + this.renderFilteredCountHeading2, + ] + : this.renderFilteredCountHeading1, + renderCell: this.renderFilteredCountCell, + }, + { + key: 'count', + sortable: useSort, + headingStyle: { maxWidth: '12em' }, + helpText: ( +
    + The number of {this.props.displayName} in the dataset + that have the given {this.props.activeField.display}{' '} + value +
    + ), + wrapCustomHeadings: ({ + headerRowIndex, + }: { + headerRowIndex: number; + }) => headerRowIndex === 0, + renderHeading: + this.props.activeFieldState.summary.internalsCount != null + ? [ + this.renderUnfilteredCountHeading1, + this.renderUnfilteredCountHeading2, + ] + : this.renderUnfilteredCountHeading1, + renderCell: this.renderUnfilteredCountCell, + }, + { + key: 'distribution', + name: 'Distribution', + width: '30%', + helpText: ( +
    + The subset of {this.props.displayName} that have the + given {this.props.activeField.display} value when + other filters have been applied +
    + ), + renderCell: this.renderDistributionCell, + }, + { + key: '%', + name: '', + width: '4em', + helpText: ( +
    + The subset of {this.props.displayName} out of all{' '} + {this.props.displayName} that have the given{' '} + {this.props.activeField.display} value +
    + ), + renderCell: this.renderPrecentageCell, + }, + ] as any + } > ); } @@ -801,6 +952,6 @@ class MembershipTable extends React.PureComponent { export default MembershipField; /** @param {HTMLElement} element */ -function isDisabledRow(element) { +function isDisabledRow(element: HTMLElement): boolean { return element.classList.contains('member__disabled'); } diff --git a/packages/libs/wdk-client/src/Components/AttributeFilter/MultiFieldFilter.jsx b/packages/libs/wdk-client/src/Components/AttributeFilter/MultiFieldFilter.tsx similarity index 50% rename from packages/libs/wdk-client/src/Components/AttributeFilter/MultiFieldFilter.jsx rename to packages/libs/wdk-client/src/Components/AttributeFilter/MultiFieldFilter.tsx index a4e61b5602..2186542f4d 100644 --- a/packages/libs/wdk-client/src/Components/AttributeFilter/MultiFieldFilter.jsx +++ b/packages/libs/wdk-client/src/Components/AttributeFilter/MultiFieldFilter.tsx @@ -15,21 +15,84 @@ import { } from '../../Components/AttributeFilter/AttributeFilterUtils'; import { preorderSeq } from '../../Utils/TreeUtils'; import Banner from '@veupathdb/coreui/lib/components/banners/Banner'; +import { + Field, + Filter, + FieldTreeNode, + MemberFilter, + OntologyTermSummary, + ValueCounts, + MultiFilterValue, +} from '../../Components/AttributeFilter/Types'; const cx = makeClassNameHelper('wdk-MultiFieldFilter'); -const getCountType = curry((countType, summary, value) => - get( - summary.valueCounts.find((count) => count.value === value), - countType, - NaN - ) +/** + * Props for the MultiFieldFilter component + */ +interface MultiFieldFilterProps { + activeField: Field; + activeFieldState: { + searchTerm?: string; + leafSummaries: OntologyTermSummary[]; + sort?: { + columnKey: string; + direction: 'asc' | 'desc'; + }; + [key: string]: any; + }; + filters: Filter[]; + fieldTree: FieldTreeNode; + displayName: string; + dataCount: number; + fillBarColor?: string; + fillFilteredBarColor?: string; + selectByDefault: boolean; + onFiltersChange: (filters: Filter[]) => void; + onMemberSort: ( + field: Field, + sort: { columnKey: string; direction: 'asc' | 'desc' } + ) => void; + onMemberSearch: (field: Field, searchTerm: string) => void; +} + +/** + * State for the MultiFieldFilter component + */ +interface MultiFieldFilterState { + operation: 'union' | 'intersect'; +} + +/** + * Type for row data used in Mesa table + */ +interface TableRow { + summary: OntologyTermSummary; + value?: string | number | null; + filter?: Filter; + isSelected?: boolean; + isLast?: boolean; +} + +const getCountType = curry( + (countType: string, summary: OntologyTermSummary, value: any) => + get( + summary.valueCounts.find((count) => count.value === value), + countType, + NaN + ) ); const getCount = getCountType('count'); const getFilteredCount = getCountType('filteredCount'); -export default class MultiFieldFilter extends React.Component { - constructor(props) { +/** + * Component for filtering multiple fields with intersection/union operations + */ +export default class MultiFieldFilter extends React.Component< + MultiFieldFilterProps, + MultiFieldFilterState +> { + constructor(props: MultiFieldFilterProps) { super(props); bindAll(this, [ 'deriveRowClassName', @@ -44,89 +107,100 @@ export default class MultiFieldFilter extends React.Component { this.state = { operation: 'intersect' }; } - getFieldByTerm(term) { + getFieldByTerm(term: string): Field | undefined { return preorderSeq(this.props.fieldTree) - .map((node) => node.field) - .find((field) => field.term === term); + .map((node: any) => node.field) + .find((field: Field) => field.term === term); } // Event handlers // Invoke callback with filters array - handleLeafFilterChange(field, value, includeUnknown, valueCounts) { + handleLeafFilterChange( + field: Field, + value: any, + includeUnknown: boolean, + valueCounts: ValueCounts + ): void { const multiFilter = this.getOrCreateFilter(this.props, this.state); const leafFilter = { field: field.term, - type: field.type, + type: field.type || '', isRange: isRange(field), value, includeUnknown, - }; - const otherLeafFilters = multiFilter.value.filters.filter( - (filter) => filter.field !== field.term - ); + } as MemberFilter; + const otherLeafFilters = ( + multiFilter.value as MultiFilterValue + ).filters.filter((filter) => filter.field !== field.term); const shouldAdd = shouldAddFilter( leafFilter, valueCounts, this.props.selectByDefault ); - const filter = { + const filter: Filter = { ...multiFilter, value: { - ...multiFilter.value, + ...(multiFilter.value as MultiFilterValue), filters: otherLeafFilters.concat(shouldAdd ? [leafFilter] : []), }, - }; + } as Filter; const otherFilters = this.props.filters.filter( - (filter) => filter.field !== this.props.activeField.term + (f) => f.field !== this.props.activeField.term ); const nextFilters = otherFilters.concat( - filter.value.filters.length > 0 ? [filter] : [] + (filter.value as MultiFilterValue).filters.length > 0 ? [filter] : [] ); this.props.onFiltersChange(nextFilters); } - handleTableSort(column, direction) { + handleTableSort(column: any, direction: 'asc' | 'desc'): void { this.props.onMemberSort(this.props.activeField, { columnKey: column.key, direction, }); } - setOperation(operation) { + setOperation(operation: 'union' | 'intersect'): void { this.setState({ operation }); const filter = this.getOrCreateFilter(this.props, this.state); - if (filter.value.filters.length > 0) { + if ((filter.value as MultiFilterValue).filters.length > 0) { const otherFilters = this.props.filters.filter( - (filter) => filter.field !== this.props.activeField.term + (f) => f.field !== this.props.activeField.term ); const nextFilters = otherFilters.concat([ - { ...filter, value: { ...filter.value, operation } }, + { + ...filter, + value: { ...(filter.value as MultiFilterValue), operation }, + } as Filter, ]); this.props.onFiltersChange(nextFilters); } } - getOrCreateFilter(props, state) { - const { term: field, type, isRange } = props.activeField; + getOrCreateFilter( + props: MultiFieldFilterProps, + state: MultiFieldFilterState + ): Filter { + const { term: field, type, isRange: isFieldRange } = props.activeField; const filter = props.filters.find( - (filter) => filter.field === props.activeField.term + (f) => f.field === props.activeField.term ); return filter != null ? filter - : { + : ({ field, type, - isRange, + isRange: isFieldRange, value: { operation: state.operation, filters: [], }, includeUnknown: false, // not sure we need this for multi filter - }; + } as Filter); } - deriveRowClassName(row) { + deriveRowClassName(row: TableRow): string { return cx( 'Row', row.value == null ? 'summary' : 'value', @@ -144,11 +218,11 @@ export default class MultiFieldFilter extends React.Component { ); } - renderDisplayHeadingName() { + renderDisplayHeadingName(): string { return this.props.activeField.display; } - renderDisplayHeadingSearch() { + renderDisplayHeadingSearch(): React.ReactNode { return (
    + onSearchTermChange={(searchTerm: string) => this.props.onMemberSearch(this.props.activeField, searchTerm) } /> @@ -171,18 +245,24 @@ export default class MultiFieldFilter extends React.Component { ); } - renderDisplayCell({ row }) { + renderDisplayCell({ row }: { row: TableRow }): React.ReactNode { + const displayField = + row.value == null ? this.getFieldByTerm(row.summary.term) : null; return (
    -
    - {row.value == null && this.getFieldByTerm(row.summary.term).display} -
    +
    {row.value == null && displayField && displayField.display}
    {this.renderRowValue(row)}
    ); } - renderCountCell({ key, row }) { + renderCountCell({ + key, + row, + }: { + key: string; + row: TableRow; + }): React.ReactNode { const internalsCount = key === 'count' ? row.summary.internalsCount @@ -205,7 +285,7 @@ export default class MultiFieldFilter extends React.Component { ); } - renderDistributionCell({ row }) { + renderDistributionCell({ row }: { row: TableRow }): React.ReactNode { const unknownCount = this.props.dataCount - row.summary.internalsCount; const notAll = row.value == null; let percent = 0; @@ -249,7 +329,7 @@ export default class MultiFieldFilter extends React.Component { ); } - renderPercentCell({ row }) { + renderPercentCell({ row }: { row: TableRow }): React.ReactNode { return ( row.value != null && ( @@ -264,13 +344,13 @@ export default class MultiFieldFilter extends React.Component { ); } - renderRowValue(row) { + renderRowValue(row: TableRow): React.ReactNode { const { value, filter, summary, isSelected } = row; if (value == null) return null; - const filterValue = get(filter, 'value', []); - const handleChange = (event) => + const filterValue = get(filter, 'value', []) as (string | number)[]; + const handleChange = (event: React.ChangeEvent) => this.handleLeafFilterChange( - this.getFieldByTerm(summary.term), + this.getFieldByTerm(summary.term)!, event.target.checked ? [value].concat(filterValue) : filterValue.filter((item) => item !== value), @@ -285,14 +365,12 @@ export default class MultiFieldFilter extends React.Component { ); } - render() { + render(): React.ReactNode { const { searchTerm = '' } = this.props.activeFieldState; const searchRe = new RegExp(escapeRegExp(searchTerm), 'i'); const filter = this.getOrCreateFilter(this.props, this.state); const leafFilters = get( - this.props.filters.find( - (filter) => filter.field === this.props.activeField.term - ), + this.props.filters.find((f) => f.field === this.props.activeField.term), 'value.filters', [] ); @@ -306,29 +384,35 @@ export default class MultiFieldFilter extends React.Component { (summary) => summary.internalsCount === 0 ); - const rows = Seq.from(this.props.activeFieldState.leafSummaries).flatMap( - (summary) => [ + const rows: TableRow[] = Seq.from(this.props.activeFieldState.leafSummaries) + .flatMap((summary) => [ { summary, filter: filtersByField[summary.term], - }, - ...summary.valueCounts.map((data, index) => ({ - summary, - value: data.value, - filter: filtersByField[summary.term], - isSelected: get(filtersByField, [summary.term, 'value'], []).includes( - data.value - ), - isLast: index === summary.valueCounts.length - 1, - })), - ] - ); + } as TableRow, + ...summary.valueCounts.map( + (data, index) => + ({ + summary, + value: data.value, + filter: filtersByField[summary.term], + isSelected: ( + get(filtersByField, [summary.term, 'value'], []) as ( + | string + | number + )[] + ).includes(data.value as string | number), + isLast: index === summary.valueCounts.length - 1, + } as TableRow) + ), + ]) + .toArray(); const filteredRows = rows.filter(({ summary }) => findAncestorFields(this.props.fieldTree, summary.term) - .dropWhile((field) => field.term !== this.props.activeField.term) + .dropWhile((field: any) => field.term !== this.props.activeField.term) .drop(1) - .some((field) => searchRe.test(field.display)) + .some((field: any) => searchRe.test(field.display)) ); return ( @@ -348,9 +432,14 @@ export default class MultiFieldFilter extends React.Component { Find {this.props.displayName} with{' '} this.handleMonthChange(Number(value))} required={required} /> void; + attributes: AttributeField[]; + tables: TableField[]; + filterAttributes: string[]; + filterTables: string[]; + selectAll: (e: React.MouseEvent) => void; + clearAll: (e: React.MouseEvent) => void; + toggleAttribute: (e: React.ChangeEvent) => void; + toggleTable: (e: React.ChangeEvent) => void; +} /** Filter text input */ -function renderFilterField(field, isChecked, handleChange) { +function renderFilterField( + field: FilterField, + isChecked: boolean, + handleChange: (e: React.ChangeEvent) => void +) { return (