Skip to content

Commit

Permalink
Add ChangeSelection action, update Jest config
Browse files Browse the repository at this point in the history
  • Loading branch information
TimboKZ committed Jul 31, 2020
1 parent 04b6d87 commit ce83fb8
Show file tree
Hide file tree
Showing 16 changed files with 2,067 additions and 1,363 deletions.
8 changes: 6 additions & 2 deletions jest.config.js
Expand Up @@ -3,9 +3,13 @@ const { compilerOptions } = require('./stories/tsconfig.json');

module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testEnvironment: 'enzyme',
setupFilesAfterEnv: ['jest-enzyme'],
moduleDirectories: ['.', 'src/', 'stories/', 'test/', 'node_modules'],
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths),
moduleNameMapper: {
...pathsToModuleNameMapper(compilerOptions.paths),
'\\.(css|less|sass|scss)$': '<rootDir>/test/__mocks__/styleMock.ts',
},
globals: {
'ts-jest': {
tsConfig: 'stories/tsconfig.json',
Expand Down
3,197 changes: 1,872 additions & 1,325 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions package.json
Expand Up @@ -43,7 +43,6 @@
"devDependencies": {
"@babel/core": "^7.10.2",
"@storybook/addon-docs": "^6.0.0-rc.19",
"@storybook/addon-links": "^6.0.0-rc.19",
"@storybook/addon-options": "^6.0.0-alpha.29",
"@storybook/addon-storyshots": "^6.0.0-rc.19",
"@storybook/addon-storysource": "^6.0.0-rc.19",
Expand All @@ -66,14 +65,18 @@
"@welldone-software/why-did-you-render": "^4.2.5",
"babel-loader": "^8.1.0",
"css-loader": "^3.5.3",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2",
"eslint": "^7.2.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-import": "^2.21.2",
"eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-react": "^7.20.0",
"eslint-plugin-react-hooks": "^4.0.4",
"eslint-plugin-simple-import-sort": "^5.0.3",
"jest": "^26.1.0",
"jest": "^26.2.2",
"jest-environment-enzyme": "^7.1.2",
"jest-enzyme": "^7.1.2",
"markdown-dynamic-codeblock-loader": "0.0.4",
"noty": "^3.2.0-beta",
"prettier": "^2.0.5",
Expand All @@ -84,7 +87,7 @@
"react-dom": "^16.10",
"react-test-renderer": "^16.13.1",
"style-loader": "^1.2.1",
"ts-jest": "^26.1.0",
"ts-jest": "^26.1.4",
"typescript": "^3.9.5"
},
"homepage": "https://github.com/TimboKZ/Chonky#readme",
Expand Down
7 changes: 4 additions & 3 deletions src/components/internal/ErrorMessage.tsx
@@ -1,4 +1,5 @@
import React from 'react';
import React, { ReactElement } from 'react';
import { Nullable } from 'tsdef';

export interface ErrorMessageProps {
message: string;
Expand All @@ -8,9 +9,9 @@ export interface ErrorMessageProps {
export const ErrorMessage = React.memo<ErrorMessageProps>((props) => {
const { message, bullets } = props;

let bulletList = null;
let bulletList: Nullable<ReactElement> = null;
if (bullets && bullets.length > 0) {
const items = [];
const items: React.ReactElement[] = [];
for (let i = 0; i < bullets.length; ++i) {
items.push(<li key={`error-bullet-${i}`}>{bullets[i]}</li>);
}
Expand Down
4 changes: 2 additions & 2 deletions src/types/file-actions.types.ts
Expand Up @@ -32,8 +32,8 @@ export interface FileAction {

export interface FileActionData {
actionId: string;
target?: Readonly<FileData>;
files?: ReadonlyArray<Readonly<FileData>>;
target?: FileData;
files?: FileData[];
}

export type FileActionHandler = (
Expand Down
7 changes: 7 additions & 0 deletions src/util/file-actions-definitions.ts
Expand Up @@ -15,6 +15,11 @@ export const ChonkyActions = {
id: 'duplicate_files_to',
},

// Actions triggered by file selections
ChangeSelection: {
id: 'change_selection',
},

// Most important action of all - opening files!
OpenFiles: {
id: 'open_files',
Expand Down Expand Up @@ -195,6 +200,8 @@ export const DefaultFileActions: FileAction[] = [
ChonkyActions.MoveFilesTo,
ChonkyActions.DuplicateFilesTo,

ChonkyActions.ChangeSelection,

ChonkyActions.OpenParentFolder,
ChonkyActions.ToggleSearch,

Expand Down
42 changes: 38 additions & 4 deletions src/util/selection.ts
@@ -1,19 +1,48 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { Nilable, Nullable } from 'tsdef';

import { dispatchFileActionState } from '../recoil/file-actions.recoil';
import {
FileArray,
FileData,
FileFilter,
ReadonlyFileArray,
} from '../types/files.types';
import { FileSelection, SelectionModifiers } from '../types/selection.types';
import { ChonkyActions } from './file-actions-definitions';
import { FileHelper } from './file-helper';

export const useSelection = (files: FileArray, disableSelection: boolean) => {
const dispatchFileAction = useRecoilValue(dispatchFileActionState);

// Create React-managed state for components that need to re-render on state change.
const [selection, setSelection] = useState<FileSelection>({});

// Dispatch an action every time selection changes.
const lastSelectionSizeForAction = useRef(0);
useEffect(() => {
const selectedFiles = SelectionHelper.getSelectedFiles(files, selection);

// We want to solve two problems here - first, we don't want to dispatch a
// selection action when Chonky is first initialized. We also don't want to
// dispatch an action if the current selection and the previous selection
// are empty (this can happen because Recoil can sometimes trigger updates
// even if object reference did not change).
if (
lastSelectionSizeForAction.current === selectedFiles.length &&
selectedFiles.length === 0
) {
return;
}
lastSelectionSizeForAction.current = selectedFiles.length;

dispatchFileAction({
actionId: ChonkyActions.ChangeSelection.id,
files: selectedFiles,
});
}, [files, dispatchFileAction, selection]);

// Pre-compute selection size for components that are only interested in the
// number of selected files but not the actual files
const selectionSize = useMemo(
Expand Down Expand Up @@ -77,7 +106,7 @@ const useSelectionModifiers = (
if (disableSelection) return;

setSelection((oldSelection) => {
if (Object.keys(oldSelection).length === 0) return oldSelection;
if (Object.keys(oldSelection).length === 0) return {};
return {};
});
}, [disableSelection, setSelection]);
Expand All @@ -104,7 +133,7 @@ export class SelectionHelper {
files: ReadonlyFileArray,
selection: Readonly<FileSelection>,
...filters: Nilable<FileFilter>[]
): ReadonlyArray<Readonly<FileData>> {
): FileData[] {
const selectedFiles = files.filter(
(file) => FileHelper.isSelectable(file) && selection[file.id] === true
) as FileData[];
Expand All @@ -114,13 +143,15 @@ export class SelectionHelper {
selectedFiles
);
}

public static getSelectionSize(
files: ReadonlyFileArray,
selection: Readonly<FileSelection>,
...filters: Nilable<FileFilter>[]
): number {
return SelectionHelper.getSelectedFiles(files, selection, ...filters).length;
}

public static isSelected(
selection: Readonly<FileSelection>,
file: Nullable<Readonly<FileData>>
Expand All @@ -147,17 +178,20 @@ export class SelectionUtil {
this.selection = selection;
}

public getSelection(): Readonly<FileSelection> {
public getSelection(): FileSelection {
return this.selection;
}

public getSelectedFiles(
...filters: Nilable<FileFilter>[]
): ReadonlyArray<Readonly<FileData>> {
): FileData[] {
return SelectionHelper.getSelectedFiles(this.files, this.selection, ...filters);
}

public getSelectionSize(...filters: Nilable<FileFilter>[]): number {
return SelectionHelper.getSelectionSize(this.files, this.selection, ...filters);
}

public isSelected(file: Nullable<FileData>): boolean {
return SelectionHelper.isSelected(this.selection, file);
}
Expand Down
30 changes: 15 additions & 15 deletions src/util/validation.ts
Expand Up @@ -39,8 +39,8 @@ export const cleanupFileArray = <AllowNull extends boolean>(
warningBullets: string[];
} => {
let cleanFileArray: AllowNull extends false ? FileArray : Nullable<FileArray>;
let warningMessage = null;
const warningBullets = [];
let warningMessage: Nullable<string> = null;
const warningBullets: string[] = [];

if (!Array.isArray(fileArray)) {
// @ts-ignore
Expand All @@ -57,21 +57,21 @@ export const cleanupFileArray = <AllowNull extends boolean>(
} else {
const indicesToBeRemoved = new Set<number>();

const seenIds = {};
const seenIds = new Set<string>();
const duplicateIdSet = new Set<string>();
const missingIdIndices = [];
const missingNameIndices = [];
const invalidTypeIndices = [];
const missingIdIndices: number[] = [];
const missingNameIndices: number[] = [];
const invalidTypeIndices: number[] = [];

for (let i = 0; i < fileArray.length; ++i) {
const file = fileArray[i];

if (isPlainObject(file)) {
if (file.id && seenIds[file.id]) {
if (file.id && seenIds.has(file.id)) {
duplicateIdSet.add(file.id);
indicesToBeRemoved.add(i);
} else {
seenIds[file.id] = true;
seenIds.add(file.id);
}

if (!file.name) {
Expand Down Expand Up @@ -271,8 +271,8 @@ export const cleanupFileActions = (
warningBullets: string[];
} => {
let cleanFileActions: FileAction[];
let warningMessage = null;
const warningBullets = [];
let warningMessage: Nullable<string> = null;
const warningBullets: string[] = [];

if (!Array.isArray(fileActions)) {
cleanFileActions = [];
Expand All @@ -284,20 +284,20 @@ export const cleanupFileActions = (
} else {
const indicesToBeRemoved = new Set<number>();

const seenIds = {};
const seenIds = new Set<string>();
const duplicateIdSet = new Set<string>();
const missingIdIndices = [];
const invalidTypeIndices = [];
const missingIdIndices: number[] = [];
const invalidTypeIndices: number[] = [];

for (let i = 0; i < fileActions.length; ++i) {
const fileAction = fileActions[i];

if (isPlainObject(fileAction)) {
if (fileAction.id && seenIds[fileAction.id]) {
if (fileAction.id && seenIds.has(fileAction.id)) {
duplicateIdSet.add(fileAction.id);
indicesToBeRemoved.add(i);
} else {
seenIds[fileAction.id] = true;
seenIds.add(fileAction.id);
}

if (!fileAction.id) {
Expand Down
2 changes: 1 addition & 1 deletion stories/03-File-Browser-basics/07-Thumbnails.stories.tsx
Expand Up @@ -31,7 +31,7 @@ export default {
},
};

export const ActionsExample = () => {
export const ThumbnailsExample = () => {
const thumbnailGenerator = (file: FileData & { delay: number }) => {
return new Promise((resolve) => {
// Delay loading by `file.delay` seconds to simulate thumb generation.
Expand Down
Expand Up @@ -16,4 +16,4 @@ export default {
},
};

export const DragNDropExample = () => null;
export const CustomStyling = () => null;
25 changes: 25 additions & 0 deletions stories/03-File-Browser-basics/10-Selections.md
@@ -0,0 +1,25 @@
## Listening to selection changes

Every time user changes the file selection, Chonky dispatches the
`ChonkyActions.ChangeSelection` action. The data for this action contains an
array of `FileData` objects corresponding to selected files. For example, to print
the names of all files in the selection, you can put the following code in your file
action handler:

```tsx
// Define the action handler
const handleFileAction = (action: FileAction, data: FileActionData) => {
if (action.id === ChonkyActions.ChangeSelection.id) {
const selectedFiles = data.files!;
const selectedFileNames = selectedFiles.map(f => f.name);
console.log('Selected file names:', selectedFileNames)
}
};

// Pass the action handler to Chonky
<FileBrowser files={[]} onFileAction={handleFileAction}>
// ...
</FileBrowser>
```

See *Using file actions* section for more details about file actions.
68 changes: 68 additions & 0 deletions stories/03-File-Browser-basics/10-Selections.stories.tsx
@@ -0,0 +1,68 @@
import 'chonky/style/main.css';

import {
ChonkyActions,
FileAction,
FileActionData,
FileArray,
FileBrowser,
FileData,
FileList,
FileSearch,
FileToolbar,
} from 'chonky';
import React from 'react';

import { createDocsObject, StoryCategories } from '../story-helpers';
// @ts-ignore
// eslint-disable-next-line
import markdown from './10-Selections.md';

const category = StoryCategories.FileBrowserBasics;
const title = 'Managing file selections';

// eslint-disable-next-line import/no-default-export
export default {
title: `${category}/${title}`,
parameters: {
docs: createDocsObject({ category, title, markdown }),
},
};

export const SelectionsExample = () => {
const files = React.useMemo<FileArray>(
() => [
{ id: 'AswQ', name: '01.psd' },
{ id: 'SdaW', name: '02.jpg' },
{ id: 'VsWq', name: '03.pdf' },
{ id: 'MsdR', name: '04.pub' },
],
[]
);
const [selectedFiles, setSelectedFiles] = React.useState<FileData[]>([]);
const handleFileAction = (action: FileAction, data: FileActionData) => {
if (action.id === ChonkyActions.ChangeSelection.id) {
setSelectedFiles(data.files!);
}
};

const selectedFilesString =
selectedFiles.length === 0
? 'None'
: selectedFiles.map((f) => f.name).join(', ');
return (
<div style={{ height: 500 }}>
<div style={{ fontSize: 20 }}>Selected files: {selectedFilesString}</div>
<br />
<FileBrowser
files={files}
onFileAction={handleFileAction}
enableDragAndDrop={true}
>
<FileToolbar />
<FileSearch />
<FileList />
</FileBrowser>
</div>
);
};

0 comments on commit ce83fb8

Please sign in to comment.