+
{map(cells, (cell) => (
| `${column.name}${ColumnCoder.VALUE_DELIMETER}${column.width}`)
- .join(ColumnCoder.COLUMN_DELIMETER);
+ return (
+ columns
+ // Encode width as divided by COLUMN_VALUE_PRECISION to shorten the resulting URL;
+ // this is an arbitrary choice to balance URL length with precision of column widths
+ .map(
+ (column) =>
+ `${column.name}${ColumnCoder.VALUE_DELIMETER}${Math.ceil(
+ column.width / ColumnCoder.COLUMN_VALUE_PRECISION
+ )}`
+ )
+ // Arbitrary limit to prevent URLs from getting too long;
+ // if users have more than 6 columns they can resize and reorder them in-app after loading the URL
+ .slice(0, 6)
+ .join(ColumnCoder.COLUMN_DELIMETER)
+ );
}
public static decode(encoded: string): Column[] {
@@ -110,7 +128,19 @@ class ColumnCoder {
.filter((unparsedColumn) => !!unparsedColumn)
.map((unparsedColumn) => {
const [name, widthAsStr] = unparsedColumn.split(ColumnCoder.VALUE_DELIMETER);
- return { name, width: parseFloat(widthAsStr) };
+ const parsedWidth = parseFloat(widthAsStr);
+ // The column width was previously encoded as a number between 0 and 1 representing the percentage of available
+ // space the column should take up, but this was difficult to work with and unintuitive for users.
+ // Now we encode the actual pixel width, which is more straightforward to understand and work with when manually editing URLs.
+ // To maintain backwards compatibility with existing URLs, we continue to support previously encoded widths as percentages,
+ // but we default them to a default column width in pixels in the decoding process.
+ // Also, multiply the parsedWidth by COLUMN_VALUE_PRECISION because it is encoded as the actual width divided by COLUMN_VALUE_PRECISION to
+ // shorten the resulting URL; this is an arbitrary choice to balance URL length with precision of column widths.
+ const width =
+ parsedWidth <= 1
+ ? DEFAULT_COLUMN_WIDTH
+ : parsedWidth * ColumnCoder.COLUMN_VALUE_PRECISION;
+ return { name, width };
});
}
}
@@ -254,9 +284,7 @@ export default class SearchParams {
.filter((parsedFolder) => parsedFolder.length <= hierarchyDepth)
.map((parsedFolder) => new FileFolder(parsedFolder)),
prov: unparsedSourceProvenance ? JSON.parse(unparsedSourceProvenance) : undefined,
- showNoValueGroups: showNoValueGroupsString
- ? JSON.parse(showNoValueGroupsString)
- : true,
+ showNoValueGroups: showNoValueGroupsString ? JSON.parse(showNoValueGroupsString) : true,
sortColumn: parsedSort
? new FileSort(parsedSort.annotationName, parsedSort.order || SortOrder.ASC)
: undefined,
diff --git a/packages/core/state/metadata/logics.ts b/packages/core/state/metadata/logics.ts
index e3dbc4980..19a44a8d3 100644
--- a/packages/core/state/metadata/logics.ts
+++ b/packages/core/state/metadata/logics.ts
@@ -26,6 +26,7 @@ import AnnotationName from "../../entity/Annotation/AnnotationName";
import { AnnotationType, AnnotationTypeIdMap } from "../../entity/AnnotationFormatter";
import FileFilter from "../../entity/FileFilter";
import FileSort, { SortOrder } from "../../entity/FileSort";
+import { DEFAULT_COLUMN_WIDTH } from "../../entity/SearchParams";
import HttpAnnotationService from "../../services/AnnotationService/HttpAnnotationService";
/**
@@ -63,8 +64,7 @@ const requestAnnotations = createLogic({
*/
const receiveAnnotationsLogic = createLogic({
async process(deps: ReduxLogicDeps, dispatch, done) {
- const actions = deps.action as ReceiveAnnotationAction;
- const annotations = actions.payload;
+ const { payload: annotations } = deps.action as ReceiveAnnotationAction;
const currentSortColumn = selection.selectors.getSortColumn(deps.getState());
const currentColumns = selection.selectors.getColumns(deps.getState());
const isQueryingAicsFms = selection.selectors.isQueryingAicsFms(deps.getState());
@@ -81,25 +81,41 @@ const receiveAnnotationsLogic = createLogic({
);
const columnNamesThatStillExist = columnsThatStillExist.map((column) => column.name);
- // Grab the first countOfColumnsToShow annotations as columns based on the following priority:
- // 1) Was already a column
- // 2) Is just in the data source
- const countOfColumnsToShow = Math.max(4, columnsThatStillExist.length);
- const remainingMaxWidth = columnsThatStillExist.reduce(
- (remainingWidth, column) => remainingWidth - column.width,
- 1
+ const newAnnotations = annotations.filter(
+ (annotation) => !columnNamesThatStillExist.includes(annotation.name)
);
- const columns = [
+
+ // TODO: To come in follow-up PR: calculate optimal column widths for new annotations based on content
+ // (currently defaulting to an arbitrary width for all new columns)
+ const widthByAnnotation: Record = {};
+ // Try to fetch values for new annotations to compute optimal column widths
+ // const widthByAnnotation = await annotationService.fetchOptimalWidthForAnnotations(
+ // newAnnotations.map((annotation) => annotation.name)
+ // );
+
+ let columns = [
...columnsThatStillExist,
- ...annotations
- .filter((annotation) => !columnNamesThatStillExist.includes(annotation.name))
- .slice(0, countOfColumnsToShow - columnsThatStillExist.length)
- .map((annotation) => ({
- name: annotation.name,
- width:
- remainingMaxWidth / (countOfColumnsToShow - columnsThatStillExist.length),
- })),
+ ...newAnnotations.map((annotation) => ({
+ name: annotation.name,
+ // TODO: Remove default when above optimal width fetching is implemented
+ width: widthByAnnotation[annotation.name] ?? DEFAULT_COLUMN_WIDTH,
+ })),
];
+
+ // If there were no columns selected, default to displaying
+ // "File Name" first for any data source
+ if (!columnsThatStillExist.length) {
+ // Remove filename annotations from columns before re-adding it at the front,
+ columns = columns.filter((column) => column.name !== AnnotationName.FILE_NAME);
+
+ // Add "File Name" back to the front of the columns array
+ columns.unshift({
+ name: AnnotationName.FILE_NAME,
+ // TODO: Remove default when above optimal width fetching is implemented
+ width: widthByAnnotation[AnnotationName.FILE_NAME] ?? DEFAULT_COLUMN_WIDTH,
+ });
+ }
+
dispatch(selection.actions.setColumns(columns));
const isCurrentSortColumnValid =
diff --git a/packages/core/state/metadata/test/logics.test.ts b/packages/core/state/metadata/test/logics.test.ts
index ffb75b777..10186d5c9 100644
--- a/packages/core/state/metadata/test/logics.test.ts
+++ b/packages/core/state/metadata/test/logics.test.ts
@@ -143,9 +143,9 @@ describe("Metadata logics", () => {
it("only dispatches columns that still exist in the data source", async () => {
// arrange
const mockColumns = mockAnnotations.map((ann) => {
- return { name: ann.name, width: 0.2 };
+ return { name: ann.name, width: 200 };
});
- const columnNoLongerExists = { name: "old column", width: 0.2 };
+ const columnNoLongerExists = { name: "old column", width: 200 };
const state = mergeState(initialState, {
selection: {
columns: [...mockColumns, columnNoLongerExists],
@@ -171,6 +171,57 @@ describe("Metadata logics", () => {
// the call should not include the column that no longer exists
expect(matchingAction?.payload.length).to.equal(mockColumns.length);
});
+
+ it("dispatches all annotations as columns when there are no existing columns", async () => {
+ // arrange: no existing columns
+ const { store, logicMiddleware, actions } = configureMockStore({
+ state: initialState,
+ logics: metadataLogics,
+ });
+
+ // act
+ store.dispatch(receiveAnnotations(mockAnnotations));
+ await logicMiddleware.whenComplete();
+
+ // assert: all annotations should be shown as columns, plus a "File Name" column prepended
+ expect(actions.includesMatch({ type: SET_COLUMNS })).to.be.true;
+ const matchingAction = actions.list
+ .filter((action) => action.type === SET_COLUMNS)
+ .at(0);
+ // 3 mock annotations + 1 prepended "file_name" column
+ expect(matchingAction?.payload.length).to.equal(mockAnnotations.length + 1);
+ expect(matchingAction?.payload[0].name).to.equal("file_name");
+ });
+
+ it("adds all new annotations as columns to existing ones", async () => {
+ // arrange: some existing columns
+ const existingColumns = [{ name: mockAnnotations[0].name, width: 300 }];
+ const state = mergeState(initialState, {
+ selection: {
+ columns: existingColumns,
+ },
+ });
+ const { store, logicMiddleware, actions } = configureMockStore({
+ state,
+ logics: metadataLogics,
+ });
+
+ // act: receive annotations that include one already shown and two new ones
+ store.dispatch(receiveAnnotations(mockAnnotations));
+ await logicMiddleware.whenComplete();
+
+ // assert: all annotations should be columns (existing kept, new ones added)
+ expect(actions.includesMatch({ type: SET_COLUMNS })).to.be.true;
+ const matchingAction = actions.list
+ .filter((action) => action.type === SET_COLUMNS)
+ .at(0);
+ expect(matchingAction?.payload.length).to.equal(mockAnnotations.length);
+ // existing column should retain its original width
+ const existingColumn = matchingAction?.payload.find(
+ (col: { name: string; width: number }) => col.name === mockAnnotations[0].name
+ );
+ expect(existingColumn?.width).to.equal(300);
+ });
});
describe("requestDataSources", () => {
diff --git a/packages/core/state/selection/actions.ts b/packages/core/state/selection/actions.ts
index 5c138fa8e..d11805f6d 100644
--- a/packages/core/state/selection/actions.ts
+++ b/packages/core/state/selection/actions.ts
@@ -140,7 +140,7 @@ export function setSortColumn(fileSort?: FileSort): SetSortColumnAction {
export interface Column {
name: string;
- width: number; // percent between 0 and 1
+ width: number; // width in pixels
}
/**
@@ -151,11 +151,14 @@ export interface Column {
export const RESIZE_COLUMN = makeConstant(STATE_BRANCH_NAME, "resize-column");
export interface ResizeColumnAction {
- payload: Column;
+ payload: {
+ name: string;
+ width?: number; // width in pixels, if not provided, defaults to auto-sizing based on content
+ };
type: string;
}
-export function resizeColumn(column: Column) {
+export function resizeColumn(column: { name: string; width?: number }): ResizeColumnAction {
return {
payload: column,
type: RESIZE_COLUMN,
@@ -181,6 +184,32 @@ export function setColumns(columns: Column[]) {
};
}
+/**
+ * REORDER_COLUMN
+ *
+ * Intention to move one or more columns to a specific index within the column list
+ * without needing to supply the full list of columns.
+ */
+export const REORDER_COLUMNS = makeConstant(STATE_BRANCH_NAME, "reorder-columns");
+
+interface ColumnReordersPayload {
+ name: string; // name of the column to move
+ moveTo: number; // index to move column to
+ width?: number; // width in pixels, defaults to existing width if not provided
+}
+
+export interface ReorderColumnsAction {
+ payload: ColumnReordersPayload[];
+ type: string;
+}
+
+export function reorderColumns(columnReorder: ColumnReordersPayload[]): ReorderColumnsAction {
+ return {
+ payload: columnReorder,
+ type: REORDER_COLUMNS,
+ };
+}
+
/**
* SELECT_FILE
*
diff --git a/packages/core/state/selection/logics.ts b/packages/core/state/selection/logics.ts
index de4b5a1c6..18538e94a 100644
--- a/packages/core/state/selection/logics.ts
+++ b/packages/core/state/selection/logics.ts
@@ -51,17 +51,20 @@ import {
CHANGE_FILE_FILTER_TYPE,
AddDataSourceReloadError,
setFileView,
- setColumns,
EXPAND_ALL_FILE_FOLDERS,
toggleNullValueGroups,
setIsLoadingSource,
+ RESIZE_COLUMN,
+ ResizeColumnAction,
+ setColumns,
+ Column,
} from "./actions";
import { interaction, metadata, ReduxLogicDeps, selection } from "../";
import * as selectionSelectors from "./selectors";
import { findChildNodes } from "../../components/DirectoryTree/findChildNodes";
import { NO_VALUE_NODE, ROOT_NODE } from "../../components/DirectoryTree/directory-hierarchy-state";
import Annotation from "../../entity/Annotation";
-import SearchParams from "../../entity/SearchParams";
+import SearchParams, { DEFAULT_COLUMN_WIDTH } from "../../entity/SearchParams";
import FileFilter, { FilterType } from "../../entity/FileFilter";
import FileFolder from "../../entity/FileFolder";
import FileSelection from "../../entity/FileSelection";
@@ -415,6 +418,39 @@ const expandAllFileFolders = createLogic({
type: [EXPAND_ALL_FILE_FOLDERS],
});
+/**
+ * Interceptor responsible for processing RESIZE_COLUMN action into
+ * automatic width adjustment based on whether the user selected a specific width
+ * or if they just want the default auto-size behavior
+ */
+const resizeColumnLogic = createLogic({
+ async process(deps: ReduxLogicDeps, dispatch, done) {
+ const { payload: column } = deps.action as ResizeColumnAction;
+ const columns = selectionSelectors.getColumns(deps.getState());
+
+ let width = column.width;
+ if (!width) {
+ // TODO: To come in follow-up
+ // const autoSizedWidth = await annotationService.fetchOptimalWidthForAnnotations(
+ // [column.name],
+ // true
+ // );
+ // width = autoSizedWidth[column.name] as number;
+ width = DEFAULT_COLUMN_WIDTH;
+ }
+
+ dispatch(
+ setColumns(
+ columns.map(
+ (c) => ({ ...c, width: c.name === column.name ? width : c.width } as Column)
+ )
+ )
+ );
+ done();
+ },
+ type: RESIZE_COLUMN,
+});
+
/**
* Interceptor responsible for processing DECODE_FILE_EXPLORER_URL actions into various
* other actions responsible for rehydrating the SearchParams into application state.
@@ -904,4 +940,5 @@ export default [
setDataSourceReloadErrorLogic,
changeQueryLogic,
removeQueryLogic,
+ resizeColumnLogic,
];
diff --git a/packages/core/state/selection/reducer.ts b/packages/core/state/selection/reducer.ts
index 2859dc7c4..d41d9a419 100644
--- a/packages/core/state/selection/reducer.ts
+++ b/packages/core/state/selection/reducer.ts
@@ -7,7 +7,6 @@ import {
SET_FILE_FILTERS,
SET_FILE_SELECTION,
SET_OPEN_FILE_FOLDERS,
- RESIZE_COLUMN,
SORT_COLUMN,
SET_SORT_COLUMN,
CHANGE_DATA_SOURCES,
@@ -31,7 +30,6 @@ import {
SetRequiresDataSourceReload,
SET_FILE_VIEW,
SetFileView,
- ResizeColumnAction,
Column,
SetColumns,
SET_COLUMNS,
@@ -40,13 +38,16 @@ import {
CHANGE_PROVENANCE_SOURCE,
ChangeProvenanceSource,
SET_IS_LOADING_DATA_SOURCE,
+ REORDER_COLUMNS,
+ ReorderColumnsAction,
} from "./actions";
import interaction from "../interaction";
-import { FileView, Source } from "../../entity/SearchParams";
+import { TOP_LEVEL_FILE_ANNOTATIONS } from "../../constants";
import FileFilter from "../../entity/FileFilter";
import FileFolder from "../../entity/FileFolder";
import FileSelection from "../../entity/FileSelection";
import FileSort, { SortOrder } from "../../entity/FileSort";
+import { DEFAULT_COLUMN_WIDTH, FileView, Source } from "../../entity/SearchParams";
import Tutorial from "../../entity/Tutorial";
import Tutorials from "../../hooks/useHelpOptions/Tutorials";
@@ -208,16 +209,40 @@ export default makeReducer(
availableAnnotationsForHierarchyLoading: true,
fileSelection: new FileSelection(),
}),
- [RESIZE_COLUMN]: (state, action: ResizeColumnAction) => ({
- ...state,
- columns: state.columns.map((column) =>
- column.name !== action.payload.name ? column : action.payload
- ),
- }),
[SET_COLUMNS]: (state, action: SetColumns) => ({
...state,
columns: action.payload,
}),
+ [REORDER_COLUMNS]: (state, action: ReorderColumnsAction) => {
+ let columns = [...state.columns];
+ for (const reorder of action.payload) {
+ const remaining = columns.filter((col) => reorder.name !== col.name);
+ let moving = columns.find((col) => reorder.name === col.name);
+ if (!moving) {
+ // Check for matching column in special top level annotations like File Name
+ // and if still no match just skip
+ const matchingSpecialAnnotation = TOP_LEVEL_FILE_ANNOTATIONS.find(
+ (a) => reorder.name === a.name || reorder.name === a.displayName
+ );
+ if (!matchingSpecialAnnotation) {
+ continue;
+ }
+ moving = {
+ name: matchingSpecialAnnotation.name,
+ width: DEFAULT_COLUMN_WIDTH,
+ };
+ }
+
+ const moveTo = Math.min(reorder.moveTo, remaining.length);
+ columns = [
+ ...remaining.slice(0, moveTo),
+ // Optionally update widths of moved columns if provided in the action
+ { ...moving, width: reorder.width ?? moving.width },
+ ...remaining.slice(moveTo),
+ ];
+ }
+ return { ...state, columns };
+ },
[SET_FILE_SELECTION]: (state, action) => ({
...state,
fileSelection: action.payload,
diff --git a/packages/core/state/selection/selectors.ts b/packages/core/state/selection/selectors.ts
index 73c9460c8..6506b04b1 100644
--- a/packages/core/state/selection/selectors.ts
+++ b/packages/core/state/selection/selectors.ts
@@ -42,10 +42,8 @@ export const hasProvenanceSource = createSelector(
(source): boolean => !!source
);
-export const isColumnWidthOverflowing = createSelector(
- [getColumns, getFileView],
- (columns, fileView): boolean =>
- fileView === FileView.LIST && columns.reduce((acc, column) => acc + column.width, 0) > 1
+export const getTotalColumnWidth = createSelector([getColumns], (columns): number =>
+ columns.reduce((acc, column) => acc + column.width, 0)
);
export const isQueryingAicsFms = createSelector(
diff --git a/packages/core/state/selection/test/reducer.test.ts b/packages/core/state/selection/test/reducer.test.ts
index 40b5639df..8c01bb7fc 100644
--- a/packages/core/state/selection/test/reducer.test.ts
+++ b/packages/core/state/selection/test/reducer.test.ts
@@ -92,7 +92,7 @@ describe("Selection reducer", () => {
const state = {
...selection.initialState,
annotationHierarchy: ["Cell Line"],
- columns: [{ name: "file_id", width: 0.5 }],
+ columns: [{ name: "file_id", width: 200 }],
filters: [new FileFilter("file_id", "1238401234")],
fileView: FileView.LIST,
openFileFolders: [new FileFolder(["AICS-11"])],
@@ -193,11 +193,11 @@ describe("Selection reducer", () => {
// arrange
const initialSelectionState = {
...selection.initialState,
- columns: [{ name: "Green", width: 0.11 }],
+ columns: [{ name: "Green", width: 110 }],
};
const columns = [
- { name: "Orange", width: 0.42 },
- { name: "Red", width: 0.47 },
+ { name: "Orange", width: 250 },
+ { name: "Red", width: 180 },
];
const action = selection.actions.setColumns(columns);
@@ -215,6 +215,115 @@ describe("Selection reducer", () => {
});
});
+ describe("REORDER_COLUMNS", () => {
+ it("moves a single column to a new index", () => {
+ // arrange
+ const state = {
+ ...selection.initialState,
+ columns: [
+ { name: "A", width: 100 },
+ { name: "B", width: 100 },
+ { name: "C", width: 100 },
+ { name: "D", width: 100 },
+ ],
+ };
+ const action = selection.actions.reorderColumns([{ name: "C", moveTo: 0 }]);
+
+ // act
+ const nextState = selection.reducer(state, action);
+
+ // assert
+ expect(
+ selection.selectors.getColumns({ ...initialState, selection: nextState })
+ ).to.deep.equal([
+ { name: "C", width: 100 },
+ { name: "A", width: 100 },
+ { name: "B", width: 100 },
+ { name: "D", width: 100 },
+ ]);
+ });
+
+ it("applies multiple reorder operations sequentially", () => {
+ // arrange
+ const state = {
+ ...selection.initialState,
+ columns: [
+ { name: "A", width: 100 },
+ { name: "B", width: 100 },
+ { name: "C", width: 100 },
+ { name: "D", width: 100 },
+ ],
+ };
+ const action = selection.actions.reorderColumns([
+ { name: "D", moveTo: 0 },
+ { name: "B", moveTo: 3 },
+ ]);
+
+ // act
+ const nextState = selection.reducer(state, action);
+
+ // assert
+ expect(
+ selection.selectors.getColumns({ ...initialState, selection: nextState })
+ ).to.deep.equal([
+ { name: "D", width: 100 },
+ { name: "A", width: 100 },
+ { name: "C", width: 100 },
+ { name: "B", width: 100 },
+ ]);
+ });
+
+ it("clamps moveTo to the end of the list when out of bounds", () => {
+ // arrange
+ const state = {
+ ...selection.initialState,
+ columns: [
+ { name: "A", width: 100 },
+ { name: "B", width: 100 },
+ { name: "C", width: 100 },
+ ],
+ };
+ const action = selection.actions.reorderColumns([{ name: "A", moveTo: 99 }]);
+
+ // act
+ const nextState = selection.reducer(state, action);
+
+ // assert
+ expect(
+ selection.selectors.getColumns({ ...initialState, selection: nextState })
+ ).to.deep.equal([
+ { name: "B", width: 100 },
+ { name: "C", width: 100 },
+ { name: "A", width: 100 },
+ ]);
+ });
+
+ it("optionally updates width of moved column", () => {
+ // arrange
+ const state = {
+ ...selection.initialState,
+ columns: [
+ { name: "A", width: 100 },
+ { name: "B", width: 100 },
+ { name: "C", width: 100 },
+ ],
+ };
+ const action = selection.actions.reorderColumns([{ name: "A", moveTo: 2, width: 200 }]);
+
+ // act
+ const nextState = selection.reducer(state, action);
+
+ // assert
+ expect(
+ selection.selectors.getColumns({ ...initialState, selection: nextState })
+ ).to.deep.equal([
+ { name: "B", width: 100 },
+ { name: "C", width: 100 },
+ { name: "A", width: 200 },
+ ]);
+ });
+ });
+
describe("SET_FILE_FILTERS", () => {
it("sets file filters", () => {
// Arrange
diff --git a/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts b/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts
index de5de647e..8ff5d3780 100644
--- a/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts
+++ b/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts
@@ -108,8 +108,8 @@ describe(`${RUN_IN_RENDERER} PersistentConfigServiceElectron`, () => {
const config = {
[PersistedConfigKeys.AllenMountPoint]: "/some/path/to/allen",
[PersistedConfigKeys.Columns]: [
- { name: "a", width: 0.25 },
- { name: "b", width: 0.3 },
+ { name: "a", width: 200 },
+ { name: "b", width: 250 },
],
[PersistedConfigKeys.CsvColumns]: ["a", "b"],
[PersistedConfigKeys.ImageJExecutable]: "/my/imagej",
|