Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Category UI state #316

Merged
merged 5 commits into from
Nov 15, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions frontend/src/context/plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { isArray } from 'lodash';
import { createContext, ReactNode, useContext } from 'react';
import { DeepPartial } from 'utility-types';

import { PluginData, PluginRepoData, PluginRepoFetchError } from '@/types';
import {
HubDimension,
PluginData,
PluginRepoData,
PluginRepoFetchError,
} from '@/types';
import { formatDate } from '@/utils';

/**
Expand Down Expand Up @@ -62,6 +67,10 @@ export function usePluginState(): PluginState {
export function usePluginMetadata() {
const { plugin } = usePluginState();

function getCategoryValue(dimension: HubDimension) {
return plugin?.category ? plugin.category[dimension] ?? [] : [];
}

return {
name: {
name: 'Plugin name',
Expand Down Expand Up @@ -184,9 +193,27 @@ export function usePluginMetadata() {
name: 'Citation information',
value: plugin?.citations,
},

workflowSteps: {
name: 'Workflow step',
value: getCategoryValue('Workflow step'),
},

imageModality: {
name: 'Image modality',
value: getCategoryValue('Image modality'),
},

supportedData: {
name: 'Supported data',
value: getCategoryValue('Supported data'),
},
};
}

export type Metadata = ReturnType<typeof usePluginMetadata>;

export type MetadataKeys = keyof Metadata;
export type MetadataKeys = keyof Omit<
Metadata,
'workflowSteps' | 'imageModality' | 'supportedData'
>;
2 changes: 2 additions & 0 deletions frontend/src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ErrorMessage } from '@/components/common/ErrorMessage';
import { PluginSearch } from '@/components/PluginSearch';
import { useLoadingState } from '@/context/loading';
import {
initCategoryFilters,
initOsiApprovedLicenseSet,
initPageResetListener,
initSearchEngine,
Expand Down Expand Up @@ -51,6 +52,7 @@ export default function Home({ error, index, licenses }: Props) {

if (index) {
initSearchEngine(index);
initCategoryFilters(index);
}

if (licenses) {
Expand Down
101 changes: 100 additions & 1 deletion frontend/src/store/search/filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { defaultsDeep } from 'lodash';
import { DeepPartial } from 'utility-types';

import pluginIndex from '@/fixtures/index.json';
import { DeriveGet, PluginIndexData } from '@/types';
import {
DeriveGet,
PluginCategory,
PluginCategoryHierarchy,
PluginIndexData,
} from '@/types';

import { filterResults } from './filters';
import { DEFAULT_STATE, SearchFormStore } from './form.store';
Expand Down Expand Up @@ -54,6 +59,23 @@ function getLicenseResults(...licenses: string[]): SearchResult[] {
return getResults(...plugins);
}

interface CategoryResultData {
category?: PluginCategory;
category_hierarchy?: PluginCategoryHierarchy;
klai95 marked this conversation as resolved.
Show resolved Hide resolved
}

function getCategoryResults(
...categoryData: CategoryResultData[]
): SearchResult[] {
const plugins = categoryData.map(({ category, category_hierarchy }) => ({
...pluginIndex[0],
category,
category_hierarchy,
}));

return getResults(...plugins);
}

function createMockFilterGet(
filters: DeepPartial<SearchFormStore['filters']> = {},
) {
Expand Down Expand Up @@ -239,4 +261,81 @@ describe('filterResults()', () => {
expect(filtered).toEqual(getLicenseResults('valid'));
});
});

const categoryResults = getCategoryResults(
{
category: {
'Supported data': ['2d', '3d'],
'Workflow step': ['foo', 'bar'],
},
},
{
category: {
'Workflow step': ['bar'],
},
},
{
category: {
'Image modality': ['foo', 'bar'],
'Supported data': ['3d'],
},
},
);

describe('filter by workflow step', () => {
it('should allow all plugins when no filters are enabled', () => {
const filtered = filterResults(createMockFilterGet(), categoryResults);
expect(filtered).toEqual(categoryResults);
});

it('should filter plugins with matching workflow steps', () => {
const filtered = filterResults(
createMockFilterGet({
workflowStep: {
bar: true,
},
}),
categoryResults,
);
expect(filtered).toEqual(categoryResults.slice(0, 2));
});
});

describe('filter by image modality', () => {
it('should allow all plugins when no filters are enabled', () => {
const filtered = filterResults(createMockFilterGet(), categoryResults);
expect(filtered).toEqual(categoryResults);
});

it('should filter plugins with matching workflow steps', () => {
const filtered = filterResults(
createMockFilterGet({
imageModality: {
bar: true,
},
}),
categoryResults,
);
expect(filtered).toEqual([categoryResults[2]]);
});
});

describe('filter by supported data', () => {
it('should allow all plugins when no filters are enabled', () => {
const filtered = filterResults(createMockFilterGet(), categoryResults);
expect(filtered).toEqual(categoryResults);
});

it('should filter plugins with matching workflow steps', () => {
const filtered = filterResults(
createMockFilterGet({
supportedData: {
'3d': true,
},
}),
categoryResults,
);
expect(filtered).toEqual([categoryResults[0], categoryResults[2]]);
});
});
});
67 changes: 62 additions & 5 deletions frontend/src/store/search/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,26 @@
import { satisfies } from '@renovate/pep440';
import { flow, intersection, isArray, isEmpty, some } from 'lodash';

import { DeriveGet } from '@/types';
import { DeriveGet, HubDimension } from '@/types';

import { searchFormStore } from './form.store';
import { SearchResult } from './search.types';
import { SearchResultTransformFunction } from './types';

function getSelectedKeys(state: Record<string, boolean>): string[] {
return Object.entries(state)
.filter(([, enabled]) => enabled)
.map(([version]) => version);
}

function filterByPythonVersion(
get: DeriveGet,
results: SearchResult[],
): SearchResult[] {
const state = get(searchFormStore).filters.pythonVersions;

// Collect all versions selected on the filter form
const selectedVersions = Object.entries(state)
.filter(([, enabled]) => enabled)
.map(([version]) => version);

const selectedVersions = getSelectedKeys(state);
if (isEmpty(selectedVersions)) {
return results;
}
Expand Down Expand Up @@ -117,11 +120,65 @@ function filterByLicense(
return results;
}

interface CreateCategoryFilterOptions {
dimension: HubDimension;
getState: (get: DeriveGet) => Record<string, boolean>;
}

/**
* Helper for creating category filter functions. Generally this allows us to
* create functions that check if the selected category terms match whatever is
* in the plugin for a particular category dimension.
*/
function createCategoryFilter({
dimension,
getState,
}: CreateCategoryFilterOptions) {
return (get: DeriveGet, results: SearchResult[]) => {
const state = getState(get);

// Check if the user enabled any of the filters. Return early otherwise.
const selectedKeys = getSelectedKeys(state);
if (isEmpty(selectedKeys)) {
return results;
}

// Return plugins that include at least one selected category.
return results.filter(
({ plugin }) =>
!isEmpty(
intersection(
plugin.category ? plugin.category[dimension] : [],
selectedKeys,
),
),
);
};
}

const filterByWorkflowStep = createCategoryFilter({
getState: (get) => get(searchFormStore).filters.workflowStep,
dimension: 'Workflow step',
});

const filterByImageModality = createCategoryFilter({
getState: (get) => get(searchFormStore).filters.imageModality,
dimension: 'Image modality',
});

const filterBySupportedData = createCategoryFilter({
getState: (get) => get(searchFormStore).filters.supportedData,
dimension: 'Supported data',
});

const FILTERS = [
filterByPythonVersion,
filterByOperatingSystem,
filterByDevelopmentStatus,
filterByLicense,
filterByWorkflowStep,
filterByImageModality,
filterBySupportedData,
];

/**
Expand Down
38 changes: 37 additions & 1 deletion frontend/src/store/search/form.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { proxy, ref } from 'valtio';
import { derive, subscribeKey } from 'valtio/utils';

import { BEGINNING_PAGE } from '@/constants/search';
import { PluginIndexData } from '@/types';
import { HubDimension, PluginIndexData } from '@/types';

import { DEFAULT_SORT_TYPE } from './constants';
import { FuseSearchEngine } from './engines';
Expand Down Expand Up @@ -45,6 +45,10 @@ export const DEFAULT_STATE = {
3.8: false,
3.9: false,
},

supportedData: {} as Record<string, boolean>,
workflowStep: {} as Record<string, boolean>,
imageModality: {} as Record<string, boolean>,
},
};

Expand Down Expand Up @@ -115,6 +119,38 @@ export function initOsiApprovedLicenseSet(licenses: SpdxLicenseData[]): void {
getOsiApprovedLicenseSet(licenses);
}

/**
* Map of hub dimensions to their corresponding UI state.
*/
const CATEGORY_FILTER_STATES: Record<HubDimension, Record<string, boolean>> = {
'Image modality': searchFormStore.filters.imageModality,
'Supported data': searchFormStore.filters.supportedData,
'Workflow step': searchFormStore.filters.workflowStep,
};

/**
* Initialization function that sets up the category filters using the provided
* data. This is necessary so that the filters only include a subset of terms
* that are included in the current plugin ecosystem.
*
* @param index The plugin index.
*/
export function initCategoryFilters(index: PluginIndexData[]): void {
for (const plugin of index) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would the triple for loop become a performance bottleneck? I am not sure if there is a way to get around it, but just wanted to get some clarity on this!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great feedback! 🤩

It could be if the amount of categories and category keys increase a lot. That would be basically O(NMK) 🤣 I can't think of a better way at the moment since:

  1. We need to iterate the entire list of N plugins.
  2. We need to iterate up to M categories.
  3. We need to iterate over all K keys so we can populate the form state.

There's no way around this because we need to figure out the entire set of keys possible, and the only way to do that is by looking at every key for every category for every plugin.

I'm not too worried also because our existing category datasets are small, and we are eventually planning to move the search to the backend so that user's don't have to compute this on their devices

if (plugin?.category) {
for (const [dimension, keys] of Object.entries(plugin.category)) {
const state = CATEGORY_FILTER_STATES[dimension as HubDimension];

for (const key of keys) {
if (key) {
state[key] = false;
}
}
}
}
}
}

export function resetFilters(): void {
for (const store of Object.values(searchFormStore.filters)) {
for (const [filterKey, value] of Object.entries(store)) {
Expand Down
16 changes: 15 additions & 1 deletion frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,32 @@ export interface PluginAuthor {
/**
* Citation formats.
* */

codemonkey800 marked this conversation as resolved.
Show resolved Hide resolved
export interface CitationData {
citation: string;
RIS: string;
BibTex: string;
APA: string;
}

export type HubDimension =
| 'Workflow step'
codemonkey800 marked this conversation as resolved.
Show resolved Hide resolved
| 'Supported data'
| 'Image modality';

export type PluginCategory = Partial<{
[key in HubDimension]: string[];
}>;

export type PluginCategoryHierarchy = Partial<{
[key in HubDimension]: string[][];
}>;

/**
* Plugin data used for indexing. This is a subset of the full plugin data.
*/
export interface PluginIndexData {
authors: PluginAuthor[] | '';
category?: PluginCategory | '';
description_content_type: string;
description: string;
description_text: string;
Expand All @@ -39,6 +52,7 @@ export interface PluginIndexData {
* Interface for plugin data response from backend.
*/
export interface PluginData extends PluginIndexData {
category_hierarchy?: PluginCategoryHierarchy;
citations?: CitationData | null;
code_repository: string;
documentation: string;
Expand Down