diff --git a/CLAUDE-TS-conversion-guide.md b/CLAUDE-TS-conversion-guide.md new file mode 100644 index 0000000000..42362545e5 --- /dev/null +++ b/CLAUDE-TS-conversion-guide.md @@ -0,0 +1,736 @@ +# 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; +``` + +--- + +## 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 + +### 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-15_ +_Session: JavaScript to TypeScript conversion and type safety improvements_ diff --git a/packages/libs/coreui/src/components/Mesa/Components/AnchoredTooltip.jsx b/packages/libs/coreui/src/components/Mesa/Components/AnchoredTooltip.tsx similarity index 63% rename from packages/libs/coreui/src/components/Mesa/Components/AnchoredTooltip.jsx rename to packages/libs/coreui/src/components/Mesa/Components/AnchoredTooltip.tsx index 91d58d1103..432de97b4c 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,19 +38,19 @@ 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 { const element = this.childWrapperRef.current; - if (!element) return undefined; + if (!element) return { left: 0, top: 0 }; const offset = element.getBoundingClientRect(); const { top, left } = offset; 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 72% rename from packages/libs/coreui/src/components/Mesa/Components/Checkbox.jsx rename to packages/libs/coreui/src/components/Mesa/Components/Checkbox.tsx index ad38513157..2743b51a57 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); } @@ -24,6 +34,8 @@ class Checkbox extends React.Component { ) : ( 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/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 65% rename from packages/libs/coreui/src/components/Mesa/Components/SelectBox.jsx rename to packages/libs/coreui/src/components/Mesa/Components/SelectBox.tsx index c189460010..0cbcc83f14 100644 --- a/packages/libs/coreui/src/components/Mesa/Components/SelectBox.jsx +++ b/packages/libs/coreui/src/components/Mesa/Components/SelectBox.tsx @@ -1,18 +1,31 @@ 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) => { @@ -20,7 +33,7 @@ class SelectBox extends React.PureComponent { ? option : { name: option.toString(), value: option }; }); - return options; + return options as SelectOption[]; } 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 62% rename from packages/libs/coreui/src/components/Mesa/Components/Toggle.jsx rename to packages/libs/coreui/src/components/Mesa/Components/Toggle.tsx index 81be3d1f00..177ccadfd9 100644 --- a/packages/libs/coreui/src/components/Mesa/Components/Toggle.jsx +++ b/packages/libs/coreui/src/components/Mesa/Components/Toggle.tsx @@ -1,14 +1,22 @@ 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) { + handleClick(e: React.MouseEvent): void { let { enabled, onChange } = this.props; if (typeof onChange === 'function') onChange(!!enabled); } @@ -18,11 +26,11 @@ class Toggle extends React.Component { className = 'Toggle' + (className ? ' ' + className : ''); className += ' ' + (enabled ? 'Toggle-On' : 'Toggle-Off'); className += disabled ? ' Toggle-Disabled' : ''; - let offStyle = { + let offStyle: React.CSSProperties = { fontSize: '1.2rem', color: '#989898', }; - let onStyle = Object.assign({}, offStyle, { + let onStyle: React.CSSProperties = Object.assign({}, offStyle, { color: '#198835', }); @@ -30,7 +38,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,10 +54,10 @@ 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; + if (count !== undefined && count < cutoff) return text; let words = text .trim() @@ -56,7 +69,7 @@ class TruncatedText extends React.Component { return this.trimPunctuation(short) + '...'; } - toggleExpansion() { + toggleExpansion(): void { let { expanded } = this.state; this.setState({ expanded: !expanded }); } @@ -65,7 +78,7 @@ class TruncatedText extends React.Component { let { expanded } = this.state; let { className, cutoff, text } = this.props; cutoff = typeof cutoff === 'number' ? cutoff : 100; - let expandable = this.wordCount(text) > cutoff; + let 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..56d7708c7c --- /dev/null +++ b/packages/libs/coreui/src/components/Mesa/Templates.tsx @@ -0,0 +1,103 @@ +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 as any; + 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 as any; + const className = 'Cell HtmlCell Cell-' + key; + const content = ( +
+ ); + const maxHeight = truncated === true ? '16em' : truncated; + + return truncated ? ( +
+ {content} +
+ ) : ( +
{content}
+ ); + }, + + heading({ key, column }: CellProps): ReactNode { + const name = column.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/Ui/ActionToolbar.jsx b/packages/libs/coreui/src/components/Mesa/Ui/ActionToolbar.tsx similarity index 67% rename from packages/libs/coreui/src/components/Mesa/Ui/ActionToolbar.jsx rename to packages/libs/coreui/src/components/Mesa/Ui/ActionToolbar.tsx index 8191506ede..8a4df32f27 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/ActionToolbar.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/ActionToolbar.tsx @@ -1,15 +1,19 @@ -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 } from '../types'; const actionToolbarClass = makeClassifier('ActionToolbar'); -class ActionToolbar extends React.PureComponent { - constructor(props) { +interface ActionToolbarProps extends Partial> { + children?: ReactNode; +} + +class ActionToolbar extends React.PureComponent> { + constructor(props: ActionToolbarProps) { super(props); this.dispatchAction = this.dispatchAction.bind(this); this.renderCounter = this.renderCounter.bind(this); @@ -19,15 +23,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: any): void { const { handler, callback } = action; const { rows, columns } = this.props; const selection = this.getSelection(); @@ -35,12 +39,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 +57,11 @@ class ActionToolbar extends React.PureComponent { ); } - renderActionItem({ action }) { + renderActionItem({ action }: { action: any }): 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); @@ -76,27 +79,31 @@ 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) => this.renderActionItem({ action }))}
); } - 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 +121,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 +137,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..67db65dcd4 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/DataCell.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/DataCell.tsx @@ -1,31 +1,51 @@ -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 } from '../types'; import { Tooltip } from '../../../components/info/Tooltip'; const dataCellClass = makeClassifier('DataCell'); -class DataCell extends React.PureComponent { - constructor(props) { +interface DataCellProps { + column: MesaColumn; + row: Row; + inline?: boolean; + options?: MesaStateProps['options']; + rowIndex: number; + columnIndex: number | null; + isChildRow?: boolean; + childRowColSpan?: number; +} + +class DataCell 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, index: rowIndex }) + : (row as any)[key]; + const cellProps = { + key, + value, + row, + column, + rowIndex, + columnIndex: columnIndex ?? 0, + }; + const { childRow } = options || {}; if (isChildRow && childRow != null) { - return childRow(rowIndex, row); + return childRow({ rowIndex, rowData: row }); } - if ('renderCell' in column) { + if ('renderCell' in column && column.renderCell) { return column.renderCell(cellProps); } @@ -49,7 +69,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 +84,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 : ( + return (column as any).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 +119,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 64% rename from packages/libs/coreui/src/components/Mesa/Ui/DataRow.jsx rename to packages/libs/coreui/src/components/Mesa/Ui/DataRow.tsx index 59f4f8cd6e..a71b8d8ca6 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/DataRow.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/DataRow.tsx @@ -1,18 +1,30 @@ 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 extends MesaStateProps { + row: Row; + rowIndex: number; +} + +interface DataRowState { + expanded: boolean; +} + +class DataRow extends React.PureComponent< + DataRowProps, + DataRowState +> { + constructor(props: DataRowProps) { super(props); this.state = { expanded: false }; this.handleRowClick = this.handleRowClick.bind(this); @@ -23,44 +35,47 @@ 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; - const { inline, onRowClick, inlineUseTooltips } = options; + if (!options) return; + const { inline, onRowClick, inlineUseTooltips } = options as any; if (!inline && !onRowClick) return; if (inline && !inlineUseTooltips) this.setState({ expanded: !this.state.expanded }); if (typeof onRowClick === 'function') onRowClick(row, rowIndex); } - handleRowMouseOver() { + handleRowMouseOver(): void { const { row, rowIndex, options } = this.props; - const { onRowMouseOver } = options; + if (!options) return; + const { onRowMouseOver } = options as any; if (typeof onRowMouseOver === 'function') { onRowMouseOver(row, rowIndex); } } - handleRowMouseOut() { + handleRowMouseOut(): void { const { row, rowIndex, options } = this.props; - const { onRowMouseOut } = options; + if (!options) return; + const { onRowMouseOut } = options as any; if (typeof onRowMouseOut === 'function') { onRowMouseOut(row, rowIndex); @@ -71,10 +86,14 @@ 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 + ? options + : ({} as any); + const inline = options && options.inline ? !expanded : false; const hasSelectionColumn = + options && + eventHandlers && typeof options.isRowSelected === 'function' && typeof eventHandlers.onRowSelect === 'function' && typeof eventHandlers.onRowDeselect === 'function'; @@ -86,7 +105,11 @@ class DataRow extends React.PureComponent { getRowId != 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 +118,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 +137,45 @@ class DataRow extends React.PureComponent { className={className .concat(showChildRow ? ' _childIsExpanded' : '') .concat(hasExpansionColumn ? ' _isExpandable' : '')} - tabIndex={this.props.options.onRowClick ? -1 : undefined} + tabIndex={ + this.props.options && (this.props.options as any).onRowClick + ? -1 + : undefined + } style={rowStyle} onClick={this.handleRowClick} onMouseOver={this.handleRowMouseOver} onMouseOut={this.handleRowMouseOut} > - {hasExpansionColumn && ( + {hasExpansionColumn && eventHandlers && uiState && ( )} - {hasSelectionColumn && ( + {hasSelectionColumn && options && eventHandlers && ( )} {columns.map((column, columnIndex) => { + let finalColumn = column; if (typeof columnDefaults === 'object') - column = Object.assign({}, columnDefaults, column); + finalColumn = Object.assign({}, columnDefaults, column); return ( @@ -153,7 +185,11 @@ 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..77acf16abb --- /dev/null +++ b/packages/libs/coreui/src/components/Mesa/Ui/DataRowList.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import DataRow from './DataRow'; +import { MesaStateProps } from '../types'; + +interface DataRowListProps extends MesaStateProps {} + +class DataRowList 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 67% rename from packages/libs/coreui/src/components/Mesa/Ui/DataTable.jsx rename to packages/libs/coreui/src/components/Mesa/Ui/DataTable.tsx index 919c0e2378..6d784e56a7 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/DataTable.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/DataTable.tsx @@ -1,17 +1,31 @@ -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 extends MesaStateProps {} + +interface DataTableState { + dynamicWidths?: number[] | null; +} + +class DataTable extends React.Component< + DataTableProps, + 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 +39,21 @@ 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(` "useStickyHeader" option enabled but no maxHeight for the table is set. Use a css height as the "tableBodyMaxHeight" option to use this setting. - `); + `) as any; return true; } - makeFirstNColumnsSticky(columns, n) { + makeFirstNColumnsSticky( + columns: MesaColumn[], + n: number + ): MesaColumn[] { const dynamicWidths = this.widthCache; if (n <= columns.length) { @@ -50,13 +67,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 +87,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 +104,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 any; } - 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 +161,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 +193,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 +215,13 @@ class DataTable extends React.Component { return (
(this.mainRef = node)} className="MesaComponent">
x !== undefined) + )} style={wrapperStyle} > (this.contentTable = node)} > - +
- {this.props.options.marginContent && ( + {this.props.options && this.props.options.marginContent && (
{this.props.options.marginContent}
@@ -221,25 +248,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..b6343c5178 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 { + 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 extends React.PureComponent< + HeadingCellProps, + 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,25 +133,31 @@ 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 } as any) + ); const content = column.renderHeading(column, columnIndex, { - SortTrigger, - HelpTrigger, - ClickBoundary, + SortTrigger: () as ReactElement, + HelpTrigger: () as ReactElement, + ClickBoundary: () as ReactElement, }); 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 }) { + const style: CSSProperties = { display: 'inline-block' }; + const stopPropagation = (node: HTMLDivElement | null) => { if (!node) return null; const instance = EventsFactory(node); instance.add('click', (e) => { @@ -143,10 +173,10 @@ class HeadingCell extends React.PureComponent { renderSortTrigger() { 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; @@ -177,56 +207,59 @@ class HeadingCell extends React.PureComponent { renderHelpTrigger() { 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 +286,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 +322,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 58% rename from packages/libs/coreui/src/components/Mesa/Ui/HeadingRow.jsx rename to packages/libs/coreui/src/components/Mesa/Ui/HeadingRow.tsx index c92e8e71d6..94470c275f 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/HeadingRow.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/HeadingRow.tsx @@ -1,31 +1,50 @@ -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 + extends Pick< + MesaStateProps, + 'columns' | 'uiState' | 'eventHandlers' | 'options' | 'actions' + > { + filteredRows: Row[]; + offsetLeft?: number; +} + +interface ColumnDefaults { + [key: string]: any; +} + +type HeadingRowColumn = MesaColumn & { + renderHeading?: + | boolean + | (( + column: MesaColumn, + columnIndex: number, + components: any + ) => ReactNode) + | ReactNode[]; +}; +class HeadingRow extends React.PureComponent< + HeadingRowProps +> { render() { const { filteredRows, 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 { isRowSelected, columnDefaults, childRow, getRowId } = options ?? {}; + const { sort, expandedRows } = uiState ?? {}; + const { onRowSelect, onRowDeselect, onExpandedRowsChange } = + eventHandlers ?? {}; const hasSelectionColumn = [ isRowSelected, onRowSelect, @@ -39,8 +58,9 @@ 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); @@ -48,14 +68,17 @@ class HeadingRow extends React.PureComponent { 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)) { + const column = col as HeadingRowColumn; + const output: any = { ...column }; + if (Array.isArray(column.renderHeading)) { output.renderHeading = - col.renderHeading.length > index ? col.renderHeading[index] : false; + column.renderHeading.length > index + ? column.renderHeading[index] + : false; } else if (!isFirstRow) { output.renderHeading = false; } - return output; + return output as HeadingRowColumn; }); return { cols, isFirstRow }; }); @@ -71,32 +94,33 @@ class HeadingRow extends React.PureComponent { heading={true} key="_expansion" rows={filteredRows} - childRow={childRow} - getRowId={getRowId} + row={filteredRows[0]} + getRowId={getRowId as any} onExpandedRowsChange={onExpandedRowsChange} expandedRows={expandedRows} /> )} - {hasSelectionColumn && ( + {hasSelectionColumn && isRowSelected && ( )} {cols.map((column, columnIndex) => { + let mergedColumn = column; if (typeof columnDefaults === 'object') - column = Object.assign({}, columnDefaults, column); + mergedColumn = { ...columnDefaults, ...column }; return ( + extends MesaStateProps { + children?: ReactNode; + headerWrapperStyle?: React.CSSProperties; +} + +class MesaController extends React.Component< + MesaControllerProps +> { + constructor(props: MesaControllerProps) { super(props); this.renderToolbar = this.renderToolbar.bind(this); this.renderActionBar = this.renderActionBar.bind(this); @@ -36,7 +44,7 @@ class MesaController extends React.Component { const props = { rows, options, columns, uiState, eventHandlers, children }; if (!options || !options.toolbar) return null; - return ; + return ; } renderActionBar() { @@ -54,13 +62,13 @@ class MesaController extends React.Component { if (!this.renderToolbar() && children) props = Object.assign({}, props, { children }); - return ; + return ; } 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 +103,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 +125,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 (
- +
) : null} @@ -133,36 +231,50 @@ class MembershipField extends React.PureComponent {
)} - +
); } } -MembershipField.defaultProps = { +(MembershipField as any).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 +286,7 @@ class MembershipTable extends React.PureComponent { ); } - constructor(props) { + constructor(props: MembershipTableProps) { super(props); bindAll( this, @@ -187,7 +299,7 @@ class MembershipTable extends React.PureComponent { 'handleSort', 'handleChangeCurrentPage', 'handleChangeRowsPerPage', - 'isItemSelected', + 'isItemSelectedImpl', 'renderCheckboxCell', 'renderCheckboxHeading', 'renderDistributionCell', @@ -204,46 +316,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 +372,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 +405,7 @@ class MembershipTable extends React.PureComponent { ); } - isSearchEnabled() { + isSearchEnabled(): boolean { return ( this.getRows().length > 10 && has(this.props, 'activeFieldState.searchTerm') && @@ -291,9 +413,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 +424,7 @@ class MembershipTable extends React.PureComponent { } if (value == null) { - this.handleUnknownChange(addItem); + this.handleUnknownChange(shouldAdd); } else { const currentFilterValue = this.props.filter == null @@ -309,9 +432,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 +444,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 +490,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 +507,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 +590,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 +630,7 @@ class MembershipTable extends React.PureComponent { }} > @@ -503,13 +638,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 +678,7 @@ class MembershipTable extends React.PureComponent { ); } - renderCountCell(value, internalsCount) { + renderCountCell(value: number, internalsCount: number | null | undefined) { return (
{value.toLocaleString()} @@ -568,7 +703,9 @@ class MembershipTable extends React.PureComponent { } renderFilteredCountHeading1() { - return this.renderCountHeading1(this.props.filteredCountHeadingPrefix); + return this.renderCountHeading1( + this.props.filteredCountHeadingPrefix || '' + ); } renderFilteredCountHeading2() { @@ -577,7 +714,7 @@ class MembershipTable extends React.PureComponent { ); } - renderFilteredCountCell({ value }) { + renderFilteredCountCell({ value }: { value: number }) { return this.renderCountCell( value, this.props.activeFieldState.summary.internalsFilteredCount @@ -585,7 +722,9 @@ class MembershipTable extends React.PureComponent { } renderUnfilteredCountHeading1() { - return this.renderCountHeading1(this.props.unfilteredCountHeadingPrefix); + return this.renderCountHeading1( + this.props.unfilteredCountHeadingPrefix || '' + ); } renderUnfilteredCountHeading2() { @@ -594,14 +733,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 +767,12 @@ 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 = '', + ...uiStateOther + } = this.props.activeFieldState; const rows = this.getRows(); let filteredRows = this.getRows(); @@ -714,7 +857,11 @@ class MembershipTable extends React.PureComponent { headingStyle: { minWidth: '12em' }, inline: true, sortable: useSort, - wrapCustomHeadings: ({ headingRowIndex }) => headingRowIndex === 0, + wrapCustomHeadings: ({ + headingRowIndex, + }: { + headingRowIndex: number; + }) => headingRowIndex === 0, renderHeading: useSearch ? [this.renderValueHeading, this.renderValueHeadingSearch] : this.renderValueHeading, @@ -735,7 +882,11 @@ class MembershipTable extends React.PureComponent { value
), - wrapCustomHeadings: ({ headingRowIndex }) => headingRowIndex === 0, + wrapCustomHeadings: ({ + headingRowIndex, + }: { + headingRowIndex: number; + }) => headingRowIndex === 0, renderHeading: this.props.activeFieldState.summary.internalsFilteredCount != null ? [ @@ -756,7 +907,11 @@ class MembershipTable extends React.PureComponent { value
), - wrapCustomHeadings: ({ headingRowIndex }) => headingRowIndex === 0, + wrapCustomHeadings: ({ + headingRowIndex, + }: { + headingRowIndex: number; + }) => headingRowIndex === 0, renderHeading: this.props.activeFieldState.summary.internalsCount != null ? [ @@ -801,6 +956,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 66% rename from packages/libs/wdk-client/src/Components/AttributeFilter/MultiFieldFilter.jsx rename to packages/libs/wdk-client/src/Components/AttributeFilter/MultiFieldFilter.tsx index a4e61b5602..95d2cd10a2 100644 --- a/packages/libs/wdk-client/src/Components/AttributeFilter/MultiFieldFilter.jsx +++ b/packages/libs/wdk-client/src/Components/AttributeFilter/MultiFieldFilter.tsx @@ -15,21 +15,83 @@ import { } from '../../Components/AttributeFilter/AttributeFilterUtils'; import { preorderSeq } from '../../Utils/TreeUtils'; import Banner from '@veupathdb/coreui/lib/components/banners/Banner'; +import { + Field, + Filter, + FieldTreeNode, + 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: string } + ) => 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 +106,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 = { + const leafFilter: Filter = { 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 Filter; + 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, - filters: otherLeafFilters.concat(shouldAdd ? [leafFilter] : []), + ...(multiFilter.value as MultiFilterValue), + filters: otherLeafFilters.concat(shouldAdd ? [leafFilter as any] : []), }, - }; + } 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: string): 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 +217,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 +244,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 +284,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 +328,7 @@ export default class MultiFieldFilter extends React.Component { ); } - renderPercentCell({ row }) { + renderPercentCell({ row }: { row: TableRow }): React.ReactNode { return ( row.value != null && ( @@ -264,16 +343,16 @@ 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 any[]; + 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), + : filterValue.filter((item: any) => item !== value), false, summary.valueCounts ); @@ -285,14 +364,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 +383,32 @@ 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 any[] + ).includes(data.value as any), + 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 +428,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 (
@@ -69,8 +82,45 @@ function AttributeSelector(props) { ); } -class AnswerTable extends React.Component { - constructor(props) { +interface AnswerTableProps extends RouteComponentProps { + meta: any; + displayInfo: DisplayInfo; + records: RecordInstance[]; + recordClass: RecordClass; + allAttributes: AttributeField[]; + visibleAttributes: AttributeField[]; + height: number; + history: History; + onSort?: (sorting: Sorting[]) => void; + onMoveColumn?: (columnName: string, newPosition: number) => void; + onChangeColumns?: (attributes: AttributeField[]) => void; + onNewPage?: (offset: number, numRecords: number) => void; + onRecordClick?: () => void; +} + +interface DataTableColumn extends AttributeField { + isDisplayable: boolean; +} + +interface DataTableRow { + [key: string]: any; +} + +interface DataTableSortingEntry { + name: string; + direction: 'ASC' | 'DESC'; +} + +interface AnswerTableState { + columns: DataTableColumn[]; + data: DataTableRow[]; + sorting: DataTableSortingEntry[]; + pendingVisibleAttributes: AttributeField[]; + attributeSelectorOpen: boolean; +} + +class AnswerTable extends React.Component { + constructor(props: AnswerTableProps) { super(props); this.handleSort = this.handleSort.bind(this); this.handleOpenAttributeSelectorClick = @@ -96,7 +146,7 @@ class AnswerTable extends React.Component { }); } - componentWillReceiveProps(nextProps) { + componentWillReceiveProps(nextProps: AnswerTableProps) { this.setState({ pendingVisibleAttributes: nextProps.visibleAttributes, }); @@ -124,11 +174,12 @@ class AnswerTable extends React.Component { } } - handleSort(datatableSorting) { - this.props.onSort( + handleSort(datatableSorting: DataTableSortingEntry[]) { + const onSort = this.props.onSort || noop; + onSort( datatableSorting.map((entry) => ({ attributeName: entry.name, - direction: entry.direction, + direction: entry.direction as 'ASC' | 'DESC', })) ); } @@ -143,10 +194,11 @@ class AnswerTable extends React.Component { this.setState(this._getInitialAttributeSelectorState()); } - handleAttributeSelectorSubmit(e) { + handleAttributeSelectorSubmit(e: React.FormEvent) { e.preventDefault(); e.stopPropagation(); - this.props.onChangeColumns(this.state.pendingVisibleAttributes); + const onChangeColumns = this.props.onChangeColumns || noop; + onChangeColumns(this.state.pendingVisibleAttributes); this.setState({ attributeSelectorOpen: false, }); @@ -155,7 +207,7 @@ class AnswerTable extends React.Component { /** * Filter unchecked checkboxes and map to attributes */ - togglePendingAttribute(attributeName, isVisible) { + togglePendingAttribute(attributeName: string, isVisible: boolean) { let pending = new Set( this.state.pendingVisibleAttributes.map((attr) => attr.name) ); @@ -215,23 +267,7 @@ class AnswerTable extends React.Component { } } -AnswerTable.propTypes = { - meta: PropTypes.object.isRequired, - displayInfo: PropTypes.object.isRequired, - records: PropTypes.array.isRequired, - recordClass: PropTypes.object.isRequired, - allAttributes: PropTypes.array.isRequired, - visibleAttributes: PropTypes.array.isRequired, - height: PropTypes.number.isRequired, - history: PropTypes.object.isRequired, - onSort: PropTypes.func, - onMoveColumn: PropTypes.func, - onChangeColumns: PropTypes.func, - onNewPage: PropTypes.func, - onRecordClick: PropTypes.func, -}; - -AnswerTable.defaultProps = { +(AnswerTable as any).defaultProps = { onSort: noop, onMoveColumn: noop, onChangeColumns: noop, @@ -242,7 +278,11 @@ AnswerTable.defaultProps = { export default wrappable(withRouter(AnswerTable)); /** Convert records array to DataTable format */ -function getDataFromRecords(records, recordClass, history) { +function getDataFromRecords( + records: RecordInstance[], + recordClass: RecordClass, + history: History +): DataTableRow[] { let attributeNames = recordClass.attributes .filter((attr) => attr.isDisplayable) .map((attr) => attr.name); @@ -253,14 +293,14 @@ function getDataFromRecords(records, recordClass, history) { pathname: `/record/${recordClass.urlSegment}/${record.id .map(property('value')) .join('/')}`, - }); + } as Location); trimmedAttrs.primary_key = `${trimmedAttrs.primary_key}`; return trimmedAttrs; }); } /** Convert sorting to DataTable format */ -function getDataTableSorting(wdkSorting) { +function getDataTableSorting(wdkSorting: Sorting[]): DataTableSortingEntry[] { return wdkSorting.map((entry) => ({ name: entry.attributeName, direction: entry.direction, @@ -268,7 +308,10 @@ function getDataTableSorting(wdkSorting) { } /** Convert attributes to DataTable format */ -function setVisibilityFlag(attributes, visibleAttributes) { +function setVisibilityFlag( + attributes: AttributeField[], + visibleAttributes: AttributeField[] +): DataTableColumn[] { let visibleSet = new Set(visibleAttributes); return attributes .filter((attr) => attr.isDisplayable) diff --git a/packages/libs/wdk-client/src/Views/Answer/AnswerTableCell.jsx b/packages/libs/wdk-client/src/Views/Answer/AnswerTableCell.tsx similarity index 58% rename from packages/libs/wdk-client/src/Views/Answer/AnswerTableCell.jsx rename to packages/libs/wdk-client/src/Views/Answer/AnswerTableCell.tsx index 33d7a8f5cb..5dc5adbe7f 100644 --- a/packages/libs/wdk-client/src/Views/Answer/AnswerTableCell.jsx +++ b/packages/libs/wdk-client/src/Views/Answer/AnswerTableCell.tsx @@ -1,17 +1,30 @@ import React from 'react'; -import PropTypes from 'prop-types'; import RecordLink from '../../Views/Records/RecordLink'; import { renderAttributeValue, wrappable } from '../../Utils/ComponentUtils'; +import { + AttributeValue, + AttributeField, + RecordInstance, + RecordClass, +} from '../../Utils/WdkModel'; // FIXME Remove hardcoded name and lookup from recordClass -let primaryKeyName = 'primary_key'; +const primaryKeyName = 'primary_key'; -function AnswerTableCell(props) { +interface AnswerTableCellProps { + value: AttributeValue; + attribute: AttributeField; + record: RecordInstance; + recordClass: RecordClass; + className?: string; +} + +function AnswerTableCell(props: AnswerTableCellProps): JSX.Element | null { if (props.value == null) { return null; } - let { value, attribute, record, recordClass } = props; + const { value, attribute, record, recordClass } = props; if (attribute.name === primaryKeyName) { return ( @@ -28,12 +41,4 @@ function AnswerTableCell(props) { } } -AnswerTableCell.propTypes = { - // TODO Put reusable propTypes in a module - value: PropTypes.string, - attribute: PropTypes.object.isRequired, - record: PropTypes.object.isRequired, - recordClass: PropTypes.object.isRequired, -}; - export default wrappable(AnswerTableCell); diff --git a/packages/libs/wdk-client/src/Views/Answer/AnswerTableHeader.jsx b/packages/libs/wdk-client/src/Views/Answer/AnswerTableHeader.tsx similarity index 56% rename from packages/libs/wdk-client/src/Views/Answer/AnswerTableHeader.jsx rename to packages/libs/wdk-client/src/Views/Answer/AnswerTableHeader.tsx index aac55616df..c99816bff5 100644 --- a/packages/libs/wdk-client/src/Views/Answer/AnswerTableHeader.jsx +++ b/packages/libs/wdk-client/src/Views/Answer/AnswerTableHeader.tsx @@ -1,16 +1,16 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { wrappable } from '../../Utils/ComponentUtils'; +import { AttributeField } from '../../Utils/WdkModel'; -function AnswerTableHeader(props) { +interface AnswerTableHeaderProps { + descriptor: AttributeField; +} + +function AnswerTableHeader(props: AnswerTableHeaderProps): JSX.Element { let { descriptor: { help, displayName }, } = props; return {displayName}; } -AnswerTableHeader.propTypes = { - descriptor: PropTypes.object.isRequired, -}; - export default wrappable(AnswerTableHeader); diff --git a/packages/libs/wdk-client/src/Views/Favorites/FavoritesList.jsx b/packages/libs/wdk-client/src/Views/Favorites/FavoritesList.tsx similarity index 69% rename from packages/libs/wdk-client/src/Views/Favorites/FavoritesList.jsx rename to packages/libs/wdk-client/src/Views/Favorites/FavoritesList.tsx index df58edb5fb..184509e0ba 100644 --- a/packages/libs/wdk-client/src/Views/Favorites/FavoritesList.jsx +++ b/packages/libs/wdk-client/src/Views/Favorites/FavoritesList.tsx @@ -1,7 +1,8 @@ import { escape } from 'lodash'; -import React, { Component } from 'react'; -import { withRouter } from 'react-router'; +import React, { Component, CSSProperties, ReactNode } from 'react'; +import { withRouter, RouteComponentProps } from 'react-router'; import BannerList from '@veupathdb/coreui/lib/components/banners/BannerList'; +import { BannerProps } from '@veupathdb/coreui/lib/components/banners/Banner'; import Icon from '../../Components/Icon/IconAlt'; import TextArea from '../../Components/InputControls/TextArea'; import TextBox from '../../Components/InputControls/TextBox'; @@ -13,13 +14,77 @@ import { import RealTimeSearchBox from '../../Components/SearchBox/RealTimeSearchBox'; import { wrappable } from '../../Utils/ComponentUtils'; import RecordLink from '../../Views/Records/RecordLink'; +import { Favorite, RecordClass } from '../../Utils/WdkModel'; +import { User } from '../../Utils/WdkUser'; import '../../Views/Favorites/wdk-Favorites.scss'; +interface Banner extends BannerProps { + id: string; +} + +interface EditCoordinates { + row: number; + column: number; +} + +interface FavoritesEvents { + searchTerm: (value: string) => void; + updateSelection: (payload: { + selectIds?: number[]; + deselectIds?: number[]; + }) => void; + sortColumn: (key: string, direction: string) => void; + filterByType: (type: string | null) => void; + editCell: (payload: { + coordinates: EditCoordinates; + key: string; + value: string; + rowData: Favorite; + }) => void; + changeCell: (value: string) => void; + saveCellData: (tableState: any, favorite: Favorite) => void; + cancelCellEdit: () => void; + deleteFavorites: (tableState: any, favorites: Favorite[]) => void; + undeleteFavorites: (tableState: any, favorites: Favorite[]) => void; +} + +interface Props extends RouteComponentProps { + tableState: any; + tableSelection: number[]; + favoritesLoading: boolean; + loadError: Error | null; + existingFavorite: Partial; + editCoordinates: EditCoordinates | Record; + editValue: string; + searchText: string; + filterByType: string | null; + deletedFavorite: Favorite | null; + user: User; + recordClasses: RecordClass[]; + searchBoxPlaceholder: string; + searchBoxHelp: string; + favoritesEvents: FavoritesEvents; + favoriteIds?: number[]; +} + +interface State { + banners: Banner[]; +} + +interface CellRendererProps { + key: string; + value: any; + row: Favorite; + rowIndex?: number; + columnIndex?: number; + column: any; +} + /** * Provides the favorites listing page. The component relies entirely on its properties. */ -class FavoritesList extends Component { - constructor(props) { +class FavoritesList extends Component { + constructor(props: Props) { super(props); this.renderIdCell = this.renderIdCell.bind(this); @@ -58,12 +123,12 @@ class FavoritesList extends Component { this.state = { banners: [] }; } - createDeletedBanner(selection) { + createDeletedBanner(selection: Favorite[]): void { if (!selection || !selection.length) return; const { banners } = this.state; const bannerId = selection.map((s) => s.displayName).join('-'); - const output = { + const output: Banner = { id: bannerId, type: 'success', message: null, @@ -106,21 +171,21 @@ class FavoritesList extends Component { this.setState({ banners }); } - handleBannerClose(index, banner) { + handleBannerClose(index: number, banner: Banner): void { const { banners } = this.state; banners.splice(index, 1); this.setState({ banners }); } - handleSearchTermChange(value) { + handleSearchTermChange(value: string): void { const { favoritesEvents } = this.props; favoritesEvents.searchTerm(value); } - renderEmptyState() { + renderEmptyState(): ReactNode { const { searchText } = this.props; const isSearching = searchText && searchText.length; - const wrapperStyle = { + const wrapperStyle: CSSProperties = { display: 'flex', flexDirection: 'column', alignItems: 'center', @@ -128,7 +193,7 @@ class FavoritesList extends Component { justifyContent: 'center', }; - const iconStyle = { + const iconStyle: CSSProperties = { fontSize: '80px', }; @@ -152,55 +217,79 @@ class FavoritesList extends Component { ); } - countFavoritesByType() { + countFavoritesByType(): Record { const { recordClasses, tableState } = this.props; const rows = MesaState.getRows(tableState); - const counts = rows.reduce((tally, { recordClassName }) => { - if (tally[recordClassName]) - tally[recordClassName] = tally[recordClassName] + 1; - else tally[recordClassName] = 1; - return tally; - }, {}); + const counts = rows.reduce( + (tally: Record, { recordClassName }: Favorite) => { + if (tally[recordClassName]) + tally[recordClassName] = tally[recordClassName] + 1; + else tally[recordClassName] = 1; + return tally; + }, + {} + ); return counts; } // RENDERERS =============================================================== - renderIdCell({ key, value, row, column }) { - const { recordClassName, primaryKey, displayName } = row; + renderIdCell({ key, value, row, column }: CellRendererProps): ReactNode { + const { recordClassName, primaryKey, displayName } = row as Favorite; const recordClass = this.getRecordClassByName(recordClassName); - const style = { whiteSpace: 'normal', wordWrap: 'break-word' }; + const style: CSSProperties = { + whiteSpace: 'normal', + wordWrap: 'break-word', + }; return (
- - {displayName} - + {recordClass ? ( + + {displayName} + + ) : ( + displayName + )}
); } - renderGroupCell({ key, value, row, rowIndex, columnIndex, column }) { + renderGroupCell({ + key, + value, + row, + rowIndex, + columnIndex, + column, + }: CellRendererProps): ReactNode { const { editCoordinates, editValue } = this.props; - const normalStyle = { display: 'flex', whiteSpace: 'normal' }; - const editStyle = { + const normalStyle: CSSProperties = { + display: 'flex', + whiteSpace: 'normal', + }; + const editStyle: CSSProperties = { marginLeft: 'auto', paddingRight: '1em', cursor: 'pointer', }; const isBeingEdited = editCoordinates && - editCoordinates.row === rowIndex && - editCoordinates.column === columnIndex; + 'row' in editCoordinates && + 'column' in editCoordinates && + (editCoordinates as EditCoordinates).row === rowIndex && + (editCoordinates as EditCoordinates).column === columnIndex; return isBeingEdited ? (
this.handleEnterKey(e, column.key)} - onChange={(newValue) => this.handleCellChange(newValue)} - autoComplete={true} - maxLength="50" - size="5" + onKeyPress={(e: React.KeyboardEvent) => + this.handleEnterKey(e, column.key) + } + onChange={(newValue: string) => this.handleCellChange(newValue)} + autoComplete="on" + maxLength={50} + size={5} /> - this.handleEditClick(rowIndex, columnIndex, key, row, value) + this.handleEditClick( + rowIndex || 0, + columnIndex || 0, + key, + row, + value + ) } className="edit-link" title="Edit This Favorite's Project Grouping" @@ -235,33 +330,45 @@ class FavoritesList extends Component { ); } - renderTypeCell({ key, value, row, column }) { - let type = this.getRecordClassByName(value); - type = type ? type.displayName : 'Unknown'; + renderTypeCell({ key, value, row, column }: CellRendererProps): ReactNode { + let recordClass = this.getRecordClassByName(value); + let type = recordClass ? recordClass.displayName : 'Unknown'; return
{type}
; } - renderNoteCell({ key, value, row, rowIndex, column, columnIndex }) { + renderNoteCell({ + key, + value, + row, + rowIndex, + column, + columnIndex, + }: CellRendererProps): ReactNode { const { editCoordinates, editValue } = this.props; - const editContainerStyle = { display: 'flex', whiteSpace: 'normal' }; - const editStyle = { + const editContainerStyle: CSSProperties = { + display: 'flex', + whiteSpace: 'normal', + }; + const editStyle: CSSProperties = { marginLeft: 'auto', paddingRight: '1em', cursor: 'pointer', }; const isBeingEdited = editCoordinates && - editCoordinates.row === rowIndex && - editCoordinates.column === columnIndex; + 'row' in editCoordinates && + 'column' in editCoordinates && + (editCoordinates as EditCoordinates).row === rowIndex && + (editCoordinates as EditCoordinates).column === columnIndex; return isBeingEdited ? (