Skip to content

feat: dynamic datafiles table#2429

Draft
abdellah257 wants to merge 1 commit into
SciCatProject:masterfrom
abdellah257:dynamic-datafile-table
Draft

feat: dynamic datafiles table#2429
abdellah257 wants to merge 1 commit into
SciCatProject:masterfrom
abdellah257:dynamic-datafile-table

Conversation

@abdellah257

@abdellah257 abdellah257 commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Description

adding the option to opt for a dynamic datafiles table.

image

Motivation

Supports custom columns, and a dynamic fetching of datafiles per batch using /api/v3/origdatablocks/fullquery instead of storing all files in memory with a /api/v3/datasets/{pid}/origdatablocks request.

Changes:

This PR include multiple changes:

  • Implementation of state management for files with a datasetId filter to support per batch loading of files.
  • A wrapper to chose from static-datafiles (old view) or dynamic-datatfiles supported in this PR.
  • Configuration options dynamicDatafilesViewEnabled and defaultDatafilesColumnsList to configure the dynamic datafiles table.
  • pipe option at table columns configuration with 3 pipes currently supported (filesize, date, timeDuration).

Tests included

  • Included for each change/fix?
  • Passing? (Merge will not be approved unless this is checked)

Documentation

  • swagger documentation updated [required]
  • official documentation updated [nice-to-have]

official documentation info

If you have updated the official documentation, please provide PR # and URL of the pages where the updates are included

Summary by Sourcery

Introduce a configurable, server-side backed dynamic datafiles table for dataset details, including selection state management and integration with existing tables and actions.

New Features:

  • Add a dynamic datafiles table component that fetches dataset files via fullquery with server-side pagination, sorting, and configurable columns.
  • Introduce configuration options to enable the dynamic datafiles view and define default datafiles columns and actions, along with per-user saved table column settings.
  • Add support for formatting table cell values via configurable pipes (date, filesize, timeDuration) and customizable empty values in the shared dynamic table.

Bug Fixes:

  • Prevent errors in configurable actions when variables are undefined and avoid repeated disabled-state evaluation issues.

Enhancements:

  • Extend files NgRx state, actions, selectors, and effects to handle dataset-scoped origdatablocks, counts, and multi-selection, including selection persistence across pages.
  • Wire the datasets module into the files feature store/effects and route the dataset datafiles tab through a wrapper that chooses static or dynamic implementation based on config.
  • Refine table and models typing around datafiles (DataFile/FileOrigdatablock) and align configurable actions and static datafiles components with these types.
  • Improve default list settings documentation to describe the dynamic datafiles table configuration and user-specific column settings.

Documentation:

  • Update contributor documentation to describe configuration and behavior of the dynamic datafiles table, including default columns and user overrides.

@abdellah257
abdellah257 requested a review from a team as a code owner June 17, 2026 14:02
@abdellah257
abdellah257 marked this pull request as draft June 17, 2026 14:03

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey - I've found 1 issue, and left some high level feedback:

  • In TimeDurationPipe.transform, the returned string for minutes/seconds currently uses m for both units (${leftMinutes}m ${leftSeconds}m); this should likely use s for seconds to avoid confusing output.
  • In DynamicDatafilesComponent.updateActionItemsFiles, the files array is rebuilt on every selection update, but tooLargeFile, totalFileSize, and selectedFileSize are all recalculated from that array rather than from the selected subset; consider explicitly basing these aggregates on the selected files to avoid unnecessary work and make the intent clearer.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `TimeDurationPipe.transform`, the returned string for minutes/seconds currently uses `m` for both units (`${leftMinutes}m ${leftSeconds}m`); this should likely use `s` for seconds to avoid confusing output.
- In `DynamicDatafilesComponent.updateActionItemsFiles`, the files array is rebuilt on every selection update, but `tooLargeFile`, `totalFileSize`, and `selectedFileSize` are all recalculated from that array rather than from the selected subset; consider explicitly basing these aggregates on the selected files to avoid unnecessary work and make the intent clearer.

## Individual Comments

### Comment 1
<location path="src/app/shared/pipes/time-duration.pipe.ts" line_range="15-16" />
<code_context>
+    const leftMinutes = minutes % 60;
+    const leftSeconds = Math.round(seconds % 60);
+
+    if (hours === 0) {
+      if (leftMinutes === 0) return `${leftSeconds}s`;
+      return `${leftMinutes}m ${leftSeconds}m`;
+    }
</code_context>
<issue_to_address>
**issue (bug_risk):** Fix unit typo for seconds in the minutes-only branch

In the `hours === 0 && leftMinutes !== 0` case, the string currently uses `${leftMinutes}m ${leftSeconds}m`. The second unit should be seconds, so this should be `${leftMinutes}m ${leftSeconds}s` to avoid outputs like `2m 30m`.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/app/shared/pipes/time-duration.pipe.ts
@abdellah257
abdellah257 force-pushed the dynamic-datafile-table branch 6 times, most recently from b225afd to c1fb025 Compare June 19, 2026 07:20
@abdellah257
abdellah257 force-pushed the dynamic-datafile-table branch from c1fb025 to 8decc49 Compare June 19, 2026 07:52
@abdellah257
abdellah257 marked this pull request as ready for review June 19, 2026 09:14

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey - I've found 4 issues, and left some high level feedback:

  • The origdatablock key logic is duplicated between getFileOrigdatablockKey in files.store.ts and a private helper in DynamicDatafilesComponent; consider reusing the exported helper to keep key generation consistent and avoid divergence.
  • The TimeDurationPipe is registered under the pipe name secondsTimeDuration but is referenced in configuration/docs as timeDuration (and mapped as timeduration in applyColumnPipe); aligning the Angular pipe name with the configuration string would reduce confusion and make it usable directly in templates.
  • In getTablePaginationConfig, pageIndex and pageSize are taken directly from queryParams (strings) and passed into the pagination model; coercing these to numbers before assigning would make the typing/usage more robust and avoid subtle issues with components expecting numeric values.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The origdatablock key logic is duplicated between `getFileOrigdatablockKey` in `files.store.ts` and a private helper in `DynamicDatafilesComponent`; consider reusing the exported helper to keep key generation consistent and avoid divergence.
- The `TimeDurationPipe` is registered under the pipe name `secondsTimeDuration` but is referenced in configuration/docs as `timeDuration` (and mapped as `timeduration` in `applyColumnPipe`); aligning the Angular pipe name with the configuration string would reduce confusion and make it usable directly in templates.
- In `getTablePaginationConfig`, `pageIndex` and `pageSize` are taken directly from `queryParams` (strings) and passed into the pagination model; coercing these to numbers before assigning would make the typing/usage more robust and avoid subtle issues with components expecting numeric values.

## Individual Comments

### Comment 1
<location path="src/app/datasets/datafiles/dynamic-datafiles/dynamic-datafiles.component.ts" line_range="162-164" />
<code_context>
+
+    this.files = Array.from(files.values());
+
+    this.selectedFileSize = this.files
+      .filter((file) => file.selected)
+      .reduce((sum, file) => sum + file.size, 0);
+    this.tooLargeFile = this.hasTooLargeFiles(this.files);
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Guard against undefined `size` values when summing `selectedFileSize` to avoid `NaN`.

Since `DataFile.size` is optional, `sum + file.size` will become `NaN` if any selected file has `size === undefined`, breaking the selected size display. Use a default instead, e.g.:

```ts
this.selectedFileSize = this.files
  .filter((file) => file.selected)
  .reduce((sum, file) => sum + (file.size ?? 0), 0);
```
</issue_to_address>

### Comment 2
<location path="src/app/datasets/datafiles/dynamic-datafiles/dynamic-datafiles.component.ts" line_range="299-305" />
<code_context>
+    return null;
+  }
+
+  getTablePaginationConfig(dataCount = 0): TablePagination {
+    const { queryParams } = this.route.snapshot;
+
+    return {
+      pageSizeOptions: this.defaultPageSizeOptions,
+      pageIndex: queryParams.pageIndex,
+      pageSize: queryParams.pageSize || this.defaultPageSize,
+      length: dataCount,
+    };
</code_context>
<issue_to_address>
**issue (bug_risk):** Normalize `pageIndex`/`pageSize` from `queryParams` to numbers before passing to pagination.

Since `queryParams.pageIndex` and `queryParams.pageSize` are strings from the URL but `TablePagination` expects numbers (as used in `onPaginationChange`), this type mismatch can lead to subtle pagination and arithmetic issues. Consider coercing them to numbers before returning, e.g.:

```ts
const pageIndex = queryParams.pageIndex ? +queryParams.pageIndex : 0;
const pageSize = queryParams.pageSize
  ? +queryParams.pageSize
  : this.defaultPageSize;

return {
  pageSizeOptions: this.defaultPageSizeOptions,
  pageIndex,
  pageSize,
  length: dataCount,
};
```
</issue_to_address>

### Comment 3
<location path="docs/contributors/default-list-settings.md" line_range="76" />
<code_context>
+### Datafiles Columns
+
+`defaultDatafilesColumnsList` is an array of column definitions. Each column
+usually points to a property on the file origdatablock row. Datafile fields are
+available under `dataFileList`.
+
</code_context>
<issue_to_address>
**suggestion (typo):** Consider adding a possessive to clarify "file origdatablock row".

This wording is a bit ungrammatical. Rephrase to something like “file’s origdatablock row” (or similar for your domain) to make the relationship clearer.

Suggested implementation:

```
### Datafiles Columns

`defaultDatafilesColumnsList` is an array of column definitions. Each column
usually points to a property on the file’s origdatablock row. Datafile fields
are available under `dataFileList`.

This page explains where to configure default list and table behavior in the
frontend.

```

If there are other occurrences of the phrase “file origdatablock row” elsewhere in the documentation, they should similarly be updated to “file’s origdatablock row” (or another possessive construction consistent with your domain terminology).
</issue_to_address>

### Comment 4
<location path="src/app/datasets/datafiles/dynamic-datafiles/dynamic-datafiles.component.ts" line_range="80" />
<code_context>
+
+  files: Array<DataFile> = [];
+  origDatablocks: FileOrigdatablock[] = [];
+  selectedOrigDatablocks: FileOrigdatablock[] = [];
+  dataset$ = this.store.select(selectCurrentDataset);
+  datasetId?: string;
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying selection handling by using the store as the single source of truth and a shared key helper so that all derived selection state is computed in one place instead of being duplicated across multiple methods and fields.

You can simplify this component meaningfully by reducing duplicated selection state and reusing existing helpers.

### 1. Use the store as the single source of truth for selection

Right now `selectedOrigDatablocks` is kept both in the store and locally, and manually synced via `refreshSelectedOrigDatablocks`, `updateRowSelectionModel`, `updateActionItemsFiles`, etc. You can remove most of that by subscribing once to the selection selector and deriving everything else from it.

Example refactor:

```ts
// fields
origDatablocks: FileOrigdatablock[] = [];
selectedOrigDatablocks: FileOrigdatablock[] = [];
rowSelectionModel = new SelectionModel<FileOrigdatablock>(true, []);
files: DataFile[] = [];

// ngOnInit
ngOnInit(): void {
  this.subscriptions.push(
    this.store.select(selectSelectedOrigDatablocks).subscribe(selected => {
      this.selectedOrigDatablocks = selected;
      this.updateSelectionDerivedState();
    }),
  );

  this.subscriptions.push(
    this.store
      .select(selectCurrentDatasetFilesWithCountAndTableSettings)
      .subscribe(({ origDatablocks, count, tablesSettings }) => {
        this.origDatablocks = origDatablocks;
        this.tablesSettings = tablesSettings;
        this.count = count;
        this.pending = false;

        this.updateSelectionDerivedState();
        this.dataSource.next(origDatablocks);
        // ...table init logic
      }),
  );
}

// centralised derivation
private updateSelectionDerivedState(): void {
  const selectedKeys = new Set(
    this.selectedOrigDatablocks.map(odb => getFileOrigdatablockKey(odb)),
  );

  this.rowSelectionModel = new SelectionModel<FileOrigdatablock>(
    true,
    this.origDatablocks.filter(odb =>
      selectedKeys.has(getFileOrigdatablockKey(odb)),
    ),
  );

  this.files = this.origDatablocks.map(odb => ({
    ...odb.dataFileList,
    selected: selectedKeys.has(getFileOrigdatablockKey(odb)),
  }));

  this.selectedFileSize = this.files
    .filter(f => f.selected)
    .reduce((sum, f) => sum + f.size, 0);
  this.tooLargeFile = this.hasTooLargeFiles(this.files);

  if (this.actionItems.datasets.length) {
    this.actionItems = {
      ...this.actionItems,
      datasets: this.actionItems.datasets.map((dataset, index) =>
        index === 0 ? { ...dataset, files: this.files } : dataset,
      ),
    };
  }
}
```

With this in place, you can drop:

- `refreshSelectedOrigDatablocks`
- `updateRowSelectionModel`
- `updateActionItemsFiles`
- The `store.select(...).pipe(take(1))` calls in `onRowClick`

And simplify `onRowClick` to just dispatch:

```ts
onRowClick({ event, sender }: IRowEvent<FileOrigdatablock>) {
  if (event === RowEventType.RowSelectionChange) {
    const fileOrigdatablock = sender.row;
    if (!fileOrigdatablock) return;

    this.store.dispatch(
      sender.checked
        ? selectOrigDatablockAction({ origDatablock: fileOrigdatablock })
        : deselectOrigDatablockAction({ origDatablock: fileOrigdatablock }),
    );
  } else if (event === RowEventType.MasterSelectionChange) {
    this.store.dispatch(
      sender.checked
        ? selectAllOrigDatablocksAction({
            origDatablocks: sender.selectionModel?.selected ?? [],
          })
        : deselectOrigDatablocksAction({ origDatablocks: this.origDatablocks }),
    );
  }
}
```

The selection flow then becomes: store → selector subscriptions → `updateSelectionDerivedState` → UI, without one-off subscriptions.

### 2. Reuse the shared `getFileOrigdatablockKey`

Instead of redefining `getFileOrigdatablockKey` locally:

```ts
private getFileOrigdatablockKey = (origDatablock: FileOrigdatablock): string =>
  [origDatablock.datasetId, origDatablock.dataFileList?.path].join(":");
```

import and use the shared helper from `files.store.ts` (or whichever module currently exports it):

```ts
import { getFileOrigdatablockKey } from "state-management/state/files.store";

// then use directly:
const key = getFileOrigdatablockKey(origDatablock);
```

This reduces duplication and guarantees consistent keying logic across the app.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +162 to +164
this.selectedFileSize = this.files
.filter((file) => file.selected)
.reduce((sum, file) => sum + file.size, 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): Guard against undefined size values when summing selectedFileSize to avoid NaN.

Since DataFile.size is optional, sum + file.size will become NaN if any selected file has size === undefined, breaking the selected size display. Use a default instead, e.g.:

this.selectedFileSize = this.files
  .filter((file) => file.selected)
  .reduce((sum, file) => sum + (file.size ?? 0), 0);

Comment on lines +299 to +305
getTablePaginationConfig(dataCount = 0): TablePagination {
const { queryParams } = this.route.snapshot;

return {
pageSizeOptions: this.defaultPageSizeOptions,
pageIndex: queryParams.pageIndex,
pageSize: queryParams.pageSize || this.defaultPageSize,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): Normalize pageIndex/pageSize from queryParams to numbers before passing to pagination.

Since queryParams.pageIndex and queryParams.pageSize are strings from the URL but TablePagination expects numbers (as used in onPaginationChange), this type mismatch can lead to subtle pagination and arithmetic issues. Consider coercing them to numbers before returning, e.g.:

const pageIndex = queryParams.pageIndex ? +queryParams.pageIndex : 0;
const pageSize = queryParams.pageSize
  ? +queryParams.pageSize
  : this.defaultPageSize;

return {
  pageSizeOptions: this.defaultPageSizeOptions,
  pageIndex,
  pageSize,
  length: dataCount,
};

### Datafiles Columns

`defaultDatafilesColumnsList` is an array of column definitions. Each column
usually points to a property on the file origdatablock row. Datafile fields are

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion (typo): Consider adding a possessive to clarify "file origdatablock row".

This wording is a bit ungrammatical. Rephrase to something like “file’s origdatablock row” (or similar for your domain) to make the relationship clearer.

Suggested implementation:

### Datafiles Columns

`defaultDatafilesColumnsList` is an array of column definitions. Each column
usually points to a property on the file’s origdatablock row. Datafile fields
are available under `dataFileList`.

This page explains where to configure default list and table behavior in the
frontend.

If there are other occurrences of the phrase “file origdatablock row” elsewhere in the documentation, they should similarly be updated to “file’s origdatablock row” (or another possessive construction consistent with your domain terminology).


files: Array<DataFile> = [];
origDatablocks: FileOrigdatablock[] = [];
selectedOrigDatablocks: FileOrigdatablock[] = [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (complexity): Consider simplifying selection handling by using the store as the single source of truth and a shared key helper so that all derived selection state is computed in one place instead of being duplicated across multiple methods and fields.

You can simplify this component meaningfully by reducing duplicated selection state and reusing existing helpers.

1. Use the store as the single source of truth for selection

Right now selectedOrigDatablocks is kept both in the store and locally, and manually synced via refreshSelectedOrigDatablocks, updateRowSelectionModel, updateActionItemsFiles, etc. You can remove most of that by subscribing once to the selection selector and deriving everything else from it.

Example refactor:

// fields
origDatablocks: FileOrigdatablock[] = [];
selectedOrigDatablocks: FileOrigdatablock[] = [];
rowSelectionModel = new SelectionModel<FileOrigdatablock>(true, []);
files: DataFile[] = [];

// ngOnInit
ngOnInit(): void {
  this.subscriptions.push(
    this.store.select(selectSelectedOrigDatablocks).subscribe(selected => {
      this.selectedOrigDatablocks = selected;
      this.updateSelectionDerivedState();
    }),
  );

  this.subscriptions.push(
    this.store
      .select(selectCurrentDatasetFilesWithCountAndTableSettings)
      .subscribe(({ origDatablocks, count, tablesSettings }) => {
        this.origDatablocks = origDatablocks;
        this.tablesSettings = tablesSettings;
        this.count = count;
        this.pending = false;

        this.updateSelectionDerivedState();
        this.dataSource.next(origDatablocks);
        // ...table init logic
      }),
  );
}

// centralised derivation
private updateSelectionDerivedState(): void {
  const selectedKeys = new Set(
    this.selectedOrigDatablocks.map(odb => getFileOrigdatablockKey(odb)),
  );

  this.rowSelectionModel = new SelectionModel<FileOrigdatablock>(
    true,
    this.origDatablocks.filter(odb =>
      selectedKeys.has(getFileOrigdatablockKey(odb)),
    ),
  );

  this.files = this.origDatablocks.map(odb => ({
    ...odb.dataFileList,
    selected: selectedKeys.has(getFileOrigdatablockKey(odb)),
  }));

  this.selectedFileSize = this.files
    .filter(f => f.selected)
    .reduce((sum, f) => sum + f.size, 0);
  this.tooLargeFile = this.hasTooLargeFiles(this.files);

  if (this.actionItems.datasets.length) {
    this.actionItems = {
      ...this.actionItems,
      datasets: this.actionItems.datasets.map((dataset, index) =>
        index === 0 ? { ...dataset, files: this.files } : dataset,
      ),
    };
  }
}

With this in place, you can drop:

  • refreshSelectedOrigDatablocks
  • updateRowSelectionModel
  • updateActionItemsFiles
  • The store.select(...).pipe(take(1)) calls in onRowClick

And simplify onRowClick to just dispatch:

onRowClick({ event, sender }: IRowEvent<FileOrigdatablock>) {
  if (event === RowEventType.RowSelectionChange) {
    const fileOrigdatablock = sender.row;
    if (!fileOrigdatablock) return;

    this.store.dispatch(
      sender.checked
        ? selectOrigDatablockAction({ origDatablock: fileOrigdatablock })
        : deselectOrigDatablockAction({ origDatablock: fileOrigdatablock }),
    );
  } else if (event === RowEventType.MasterSelectionChange) {
    this.store.dispatch(
      sender.checked
        ? selectAllOrigDatablocksAction({
            origDatablocks: sender.selectionModel?.selected ?? [],
          })
        : deselectOrigDatablocksAction({ origDatablocks: this.origDatablocks }),
    );
  }
}

The selection flow then becomes: store → selector subscriptions → updateSelectionDerivedState → UI, without one-off subscriptions.

2. Reuse the shared getFileOrigdatablockKey

Instead of redefining getFileOrigdatablockKey locally:

private getFileOrigdatablockKey = (origDatablock: FileOrigdatablock): string =>
  [origDatablock.datasetId, origDatablock.dataFileList?.path].join(":");

import and use the shared helper from files.store.ts (or whichever module currently exports it):

import { getFileOrigdatablockKey } from "state-management/state/files.store";

// then use directly:
const key = getFileOrigdatablockKey(origDatablock);

This reduces duplication and guarantees consistent keying logic across the app.

@abdellah257
abdellah257 marked this pull request as draft July 10, 2026 08:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant