Skip to content

Commit a282146

Browse files
duranbAaronPlavedandelany
authored
Sequence metadata UI (#1905)
* add workspace file metadata types, API methods, and fetching * add workspace file metadata UI — table columns, metadata banner, and read-only support * workspace panel refactor, metadata API fixes, and sidebar button fix * extract PanelHeader component from repeated section title styling * add user metadata editor, open folder navigation, and file browser search improvements - Add CodeMirror-based JSON editor for user metadata in the metadata panel with syntax highlighting, validation, and auto-save on blur/file switch - Add "Open Folder" button in content area and context menu to navigate the file browser to a selected folder - Extend file browser search to match against metadata fields (createdBy, lastEditedBy, version, user metadata) - Move read-only permission handling from editor components to FileMetadataBanner - Add success toast for read-only status changes * Column sizing fixes * Panel sizing * Tweak workspace file browse column sizes for compactness * fix workspace metadata reactivity and type safety improvements * Right sidebar icon swap for dictionary * Tweak user metadata input to use explicit edit, save, cancel buttons. Auto discard changes when changing tabs or files. * Tooltip improvements for workspace sidebar and icon tray * Refactor * Prettier * Test fixes * fix(sequencing): configure both readOnly and editable facets in editor Add EditorView.editable configuration alongside EditorState.readOnly in SequenceEditor and WorkspaceMetadataPanel components. Both facets need to be properly configured for CodeMirror to correctly handle editor editability state changes. Changes: - Add EditorView.editable.of() to compartment reconfiguration in both components - Refactor SequenceEditor readonly logic to use isEditable variable for clarity - Reorder onReadOnlyChange prop declaration in SequenceEditor This ensures the editor properly responds to readonly state changes by configuring both the readOnly state facet and the editable view facet. * refactor(workspace): extract panel toggle logic into reusable function Extract duplicate click handler logic into a `togglePanel` function to reduce code duplication in WorkspaceRightIconRail component. The function handles toggling between metadata, command, and dictionary panels, closing the active panel if clicked again or switching to a new panel otherwise. This improves maintainability by following the DRY principle and makes the component easier to modify in the future. * refactor(workspace): improve variable declarations in WorkspaceRightPanel - Add explicit type imports (ArgTextDef, TimeTagInfo) from sequence languages - Declare component variables at top for better organization and clarity - Simplify timeTagNode reactive statement formatting - Improve code readability by making variable declarations explicit This refactoring improves code maintainability by organizing variable declarations in a clear, conventional pattern at the component's top level. * fix(workspace): clarify metadata message is for folders, not files * fix(workspace): await file save and refresh contents after save This fixes a potential race condition where the workspace UI might not reflect the saved state and ensures proper async/await flow in the save operation. * refactor: remove version column and metadata from workspace UI * Optimistically update the metadata user object so it doesn't flash on save. * fix linting errors * fix: add overwrite merge behavior to workspace metadata POST requests * feat(ui): add column resize support and centralize cookie management - Add columnShiftResize prop to BulkActionDataGrid and SingleActionDataGrid components - Create centralized cookie utilities (setCookie, getCookie) with consistent expiration handling - Implement column state persistence in WorkspaceFileBrowser using cookies - Move cookie constants to centralized location (constants/cookies.ts) - Refactor SidebarProvider to use new cookie utilities instead of manual document.cookie - Add comprehensive test coverage for cookie utility functions This change improves code maintainability by consolidating cookie management logic and enables users to persist their column resize preferences across sessions. * feat(workspace): add read-only file protection to context menu - Add `hasReadOnlyNodes` prop to track selection of read-only files - Disable rename and delete operations when read-only files are selected - Display appropriate error messages for read-only restrictions - Visually indicate "Move" is disabled (with reduced opacity) while keeping "Copy" available for read-only files - Update permission checks to consider read-only status alongside existing permissions This prevents users from accidentally modifying or deleting files that should remain read-only, improving data integrity in the workspace. * fix test * refactor: simplify actions click handling and add open folder button - Remove custom event dispatcher from WorkspaceLeftIconRail component - Use existing handleTabClick function for actions button instead of custom event - Simplify component interface by removing actionsClick event handler prop - Add "Open Folder" button to workspace document view for easier folder access This refactoring reduces code complexity by consolidating tab click handling into a single method while improving UX with the new folder access feature. * refactor(workspace): reorganize component props and restructure layout - Move currentBreadcrumbPath export to group with other props in WorkspaceSidebar - Restructure workspace content area component hierarchy - Improve code organization without changing functionality This refactoring improves code readability by grouping related exports together and simplifying the component structure in the workspace page. * refactor(e2e): improve TypeScript imports and fix action selector - Use type-only imports for Locator and Page to optimize bundle size - Add definite assignment assertions (!:) to Locator properties initialized in constructor - Update action sidebar selector from 'complementary' to 'tablist' role for more accurate element targeting These changes improve TypeScript type safety and ensure more reliable E2E test selectors in the Action fixture. * refactor(workspace): simplify modals and add read-only file protection - Remove unused workspace and user props from ImportWorkspaceFileModal - Remove enableContextMenu prop from WorkspaceTreeView component calls - Add selectionHasReadOnlyNodes prop to MoveItemToWorkspaceModal - Prevent moving read-only files with appropriate error messaging - Update permission checks to account for read-only file selections - Clean up unused type imports and component props This simplifies the component interface by removing unnecessary props that were being passed through multiple layers, and adds protection against moving read-only files to prevent data integrity issues. * fix(text-editor): configure editable state alongside readonly **Changes:** - Configure both `EditorState.readOnly` and `EditorView.editable` facets to properly control editor interactivity - Update E2E test selectors to use more specific button roles in tabpanel instead of generic labels - Reorder component props for consistency (move callback to end) **Why:** The text editor was not properly respecting readonly/preview/loading states because only the `readOnly` facet was configured. CodeMirror requires both `readOnly` and `editable` facets to be set for proper behavior. The E2E test updates improve selector reliability by targeting more specific button elements. * remove log * fix: improve error handling in error display and bulk file operations This commit addresses two error handling issues: 1. Error store: Stringify object-type error causes to prevent displaying "[object Object]" in error messages. Previously, object causes were coerced to string directly, losing valuable debugging information. 2. Bulk file operations: Track failed file operations that are not conflicts in the bulkMoveWorkspaceItems function. Non-success responses during overwrite operations are now properly added to failedFileOperations array for better error reporting. These changes improve error visibility and ensure failed operations are properly tracked and reported to users. * fix(effects): improve error messaging for failed workspace item moves * fix(ui): add z-index to workspace content pane so tooltips don't go under right panel * set minSize of left/right workspace panels to 20 to workaround issue with being always set to minSize on first open * suppressSizeToFit on workspace file browser to preserve users' saved column width * update ConsoleLog to not wrap everything in pre tags, update action runs to render better previews for JSON objects * linting * fix action tab styling * Action tab content switching fixes * Style fix * fix: improve workspace file browser column sizing and add reset option The Name column now uses flex:1 exclusively (never converted to fixed width), so it fills remaining space instantly on panel open/close with no flash. Metadata columns preserve user-set widths via cookies with suppressSizeToFit. - Fix "Fit Columns to Available Space" context menu by temporarily overriding suppressSizeToFit on resizable columns for explicit user actions - Add "Reset Column Layout" context menu option to all DataGrid instances, with cookie cleanup in the workspace file browser - Guard ResizeObserver against collapsed containers (width < 50px) - Preserve Name column flex:1 by stripping width/flex from saved column state * Fix panel sizing after closing and re-opening * Fixes and cleanup * Preserve user-driven name column width changes. Add reset layout button to column picker. * Bug fix * Refactor * Revert change --------- Co-authored-by: AaronPlave <aaronplave@gmail.com> Co-authored-by: Dan Delany <daniel.t.delany@jpl.nasa.gov>
1 parent 2632b0f commit a282146

50 files changed

Lines changed: 2949 additions & 976 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

e2e-tests/fixtures/Action.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
import { expect, Locator, Page } from '@playwright/test';
1+
import { expect, type Locator, type Page } from '@playwright/test';
22
import { adjectives, animals, colors, uniqueNamesGenerator } from 'unique-names-generator';
33
import { setFileInputByFilepath } from '../utilities/helpers';
44

55
export class Action {
66
actionDescription: string = uniqueNamesGenerator({ dictionaries: [adjectives, colors, animals] });
77
actionName: string = uniqueNamesGenerator({ dictionaries: [adjectives, colors, animals] });
88
actionPath: string = 'e2e-tests/data/aerie-action-demo.js';
9-
actionsSidebarTab: Locator;
10-
createActionButton: Locator;
11-
createModal: Locator;
9+
actionsSidebarTab!: Locator;
10+
createActionButton!: Locator;
11+
createModal!: Locator;
1212

1313
constructor(
1414
public page: Page,
@@ -85,15 +85,22 @@ export class Action {
8585
// Verify we navigated to the run detail view
8686
await expect(this.page.getByRole('heading', { name: /Run #\d+/ })).toBeVisible({ timeout: 15000 });
8787
// Wait for a terminal status (Complete or Failed) in the main content area
88-
const mainContent = this.page.getByRole('main');
89-
await expect(mainContent.getByLabel('Complete').or(mainContent.getByLabel('Failed'))).toBeVisible({
88+
await expect(
89+
this.page
90+
.getByRole('tabpanel')
91+
.getByRole('button', { name: `Complete ${this.actionName}` })
92+
.or(this.page.getByRole('tabpanel').getByRole('button', { name: `Failed ${this.actionName}` })),
93+
).toBeVisible({
9094
timeout: 30000,
9195
});
9296
}
9397

9498
async selectActionInSidebar(): Promise<void> {
9599
// Click the action in the sidebar list (scoped to complementary to avoid matching other elements)
96-
await this.page.getByRole('complementary').getByRole('button', { name: this.actionName }).click();
100+
await this.page
101+
.getByRole('tabpanel')
102+
.getByRole('button', { name: `${this.actionName} Last run` })
103+
.click();
97104
await expect(this.page.getByRole('heading', { name: this.actionName })).toBeVisible();
98105
}
99106

e2e-tests/fixtures/Workspace.ts

Lines changed: 76 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,34 @@ import { getWorkspacesUrl } from '../../src/utilities/routes';
66
import { generateRandomName, hoverRowAndWaitForButton, setFileInputByBuffer } from '../utilities/helpers';
77

88
export class Workspace {
9-
editSequenceButton: Locator;
10-
fileInput: Locator;
11-
folderNameInput: Locator;
9+
editSequenceButton!: Locator;
10+
fileInput!: Locator;
11+
folderNameInput!: Locator;
1212
jsonPath: string = 'e2e-tests/data/ban00001.json';
13-
navButtonSequences: Locator;
14-
navButtonSequencesMenu: Locator;
15-
pageLoadingLocatorWithData: Locator;
16-
saveSequenceButton: Locator;
17-
searchInput: Locator;
18-
sequenceEditor: Locator;
19-
sequenceNameInput: Locator;
20-
textEditor: Locator;
21-
workspaceCollaboratorInput: Locator;
22-
workspaceContextMenuButton: Locator;
23-
workspaceFileBrowserButton: Locator;
24-
workspaceFileContextMenu: Locator;
25-
workspaceFileGrid: Locator;
26-
workspaceHeaderMenu: Locator;
27-
workspaceSettingsButton: Locator;
28-
workspaceSidebar: Locator;
13+
metadataCancelButton!: Locator;
14+
metadataEditButton!: Locator;
15+
metadataPanel!: Locator;
16+
metadataSaveButton!: Locator;
17+
metadataTabButton!: Locator;
18+
navButtonSequences!: Locator;
19+
navButtonSequencesMenu!: Locator;
20+
pageLoadingLocatorWithData!: Locator;
21+
readOnlyCheckbox!: Locator;
22+
rightPanelCollapseButton!: Locator;
23+
saveSequenceButton!: Locator;
24+
searchInput!: Locator;
25+
sequenceEditor!: Locator;
26+
sequenceNameInput!: Locator;
27+
textEditor!: Locator;
28+
userMetadataEditor!: Locator;
29+
workspaceCollaboratorInput!: Locator;
30+
workspaceContextMenuButton!: Locator;
31+
workspaceFileBrowserButton!: Locator;
32+
workspaceFileContextMenu!: Locator;
33+
workspaceFileGrid!: Locator;
34+
workspaceHeaderMenu!: Locator;
35+
workspaceSettingsButton!: Locator;
36+
workspaceSidebar!: Locator;
2937

3038
constructor(
3139
public page: Page,
@@ -85,30 +93,23 @@ export class Workspace {
8593
}
8694

8795
async deleteFile(fileName: string): Promise<void> {
88-
const row = this.workspaceFileGrid.getByRole('row', { name: fileName });
89-
const deleteButton = row.getByRole('button', { name: 'Delete' });
90-
await hoverRowAndWaitForButton(this.page, row, deleteButton);
91-
await deleteButton.click();
96+
await this.openFileContextMenu(fileName);
97+
await this.workspaceFileContextMenu.getByRole('menuitem', { name: 'Delete' }).click();
9298
await this.page.locator('#modal-container').getByRole('button', { name: 'Delete' }).click();
9399
await this.waitForToast('Workspace File Deleted Successfully');
94100
}
95101

96102
async deleteFolder(folderName: string): Promise<void> {
97-
const row = this.workspaceFileGrid.getByRole('row', { name: folderName });
98-
const deleteButton = row.getByRole('button', { name: 'Delete' });
99-
await hoverRowAndWaitForButton(this.page, row, deleteButton);
100-
await deleteButton.click();
103+
await this.openFileContextMenu(folderName);
104+
await this.workspaceFileContextMenu.getByRole('menuitem', { name: 'Delete' }).click();
101105
await this.page.locator('#modal-container').getByRole('button', { name: 'Delete' }).click();
102106
await this.waitForToast('Workspace Folder Deleted Successfully');
103107
}
104108

105109
async deleteSequence(sequenceName: string): Promise<void> {
106-
const row = this.workspaceFileGrid.getByRole('row', { name: sequenceName });
107-
const deleteButton = row.getByRole('button', { name: 'Delete' });
108-
await hoverRowAndWaitForButton(this.page, row, deleteButton);
109-
await deleteButton.click();
110+
await this.openFileContextMenu(sequenceName);
111+
await this.workspaceFileContextMenu.getByRole('menuitem', { name: 'Delete' }).click();
110112
await this.page.locator('#modal-container').getByRole('button', { name: 'Delete' }).click();
111-
112113
await this.waitForToast('Workspace File Deleted Successfully');
113114
}
114115

@@ -158,6 +159,18 @@ export class Workspace {
158159
await this.sequenceNameInput.blur();
159160
}
160161

162+
/**
163+
* Type content into the user metadata JSON editor (CodeMirror).
164+
* Clears existing content first.
165+
*/
166+
async fillUserMetadata(content: string): Promise<void> {
167+
// Focus the CodeMirror editor
168+
await this.userMetadataEditor.click();
169+
// Select all and replace
170+
await this.page.keyboard.press('ControlOrMeta+a');
171+
await this.page.keyboard.type(content);
172+
}
173+
161174
getFileRow(name: string): Locator {
162175
return this.workspaceFileGrid.getByRole('row', { name });
163176
}
@@ -191,12 +204,32 @@ export class Workspace {
191204

192205
async openFileContextMenu(fileName: string): Promise<void> {
193206
const row = this.workspaceFileGrid.getByRole('row', { name: fileName });
207+
// AG Grid can briefly detach/reattach rows during re-renders (e.g., after a delete
208+
// triggers a tree refetch). Wait for the row to stabilize before interacting.
209+
await row.waitFor({ state: 'visible', timeout: 5000 });
210+
try {
211+
await row.scrollIntoViewIfNeeded();
212+
} catch {
213+
// Row was detached during AG Grid re-render; re-wait and retry
214+
await row.waitFor({ state: 'visible', timeout: 5000 });
215+
}
194216
const moreActionsButton = row.getByLabel('More actions');
195217
await hoverRowAndWaitForButton(this.page, row, moreActionsButton);
196218
await moreActionsButton.click();
197219
await this.workspaceFileContextMenu.waitFor({ state: 'visible' });
198220
}
199221

222+
/**
223+
* Open the right-side metadata panel by clicking the metadata tab icon.
224+
* If the panel is already open on the metadata tab, this is a no-op.
225+
*/
226+
async openMetadataPanel(): Promise<void> {
227+
// Click the Metadata tab button in the right icon rail
228+
await this.metadataTabButton.click();
229+
// Wait for the metadata panel content to appear
230+
await this.page.getByText('User metadata', { exact: true }).waitFor({ state: 'visible', timeout: 5000 });
231+
}
232+
200233
async openWorkspaceContextMenu(): Promise<void> {
201234
await this.workspaceContextMenuButton.click();
202235
await this.workspaceHeaderMenu.waitFor({ state: 'attached' });
@@ -241,24 +274,28 @@ export class Workspace {
241274
// Use locator chain: find by aria-label within the modal
242275
this.fileInput = page.locator('#modal-container input[type="file"][aria-label="File(s)"]');
243276
this.folderNameInput = page.locator('#modal-container').getByRole('textbox', { name: 'Folder Name' });
277+
this.metadataEditButton = page.getByRole('button', { name: 'Edit user metadata' });
278+
this.metadataCancelButton = page.locator('.user-metadata-editor + div').getByRole('button', { name: 'Cancel' });
279+
this.metadataPanel = page.locator('.user-metadata-editor').first();
280+
this.metadataSaveButton = page.locator('.user-metadata-editor + div').getByRole('button', { name: 'Save' });
281+
this.metadataTabButton = page.getByRole('button', { name: 'Metadata' });
244282
this.navButtonSequences = page.locator('.nav-button:has-text("Sequences")');
245283
this.navButtonSequencesMenu = this.navButtonSequences.getByRole('menu');
246284
this.page = page;
247-
this.pageLoadingLocatorWithData = page.getByRole('complementary').getByText('Loading workspace').first();
285+
this.pageLoadingLocatorWithData = page.getByText('Loading workspace').first();
286+
this.readOnlyCheckbox = page.locator('#read-only');
287+
this.rightPanelCollapseButton = page.getByRole('button', { name: /Collapse panel|Expand panel/ }).last();
248288
this.saveSequenceButton = page.getByRole('button', { name: 'Save' });
249289
this.searchInput = page.getByPlaceholder('Search files and folders');
250290
this.sequenceEditor = page.locator('.cm-activeLine').first();
251291
this.sequenceNameInput = page.locator('#modal-container').getByRole('textbox', { name: 'File Name' });
252292
this.textEditor = page.locator('.cm-activeLine').nth(2);
293+
this.userMetadataEditor = page.locator('.user-metadata-editor .cm-content').first();
253294
this.workspaceFileContextMenu = page.getByTestId('context-menu');
254295
this.workspaceFileGrid = page.getByRole('treegrid');
255296
this.workspaceHeaderMenu = page.getByTestId('workspace-header-menu');
256-
this.workspaceSidebar = page.getByRole('complementary');
257-
this.workspaceContextMenuButton = this.workspaceSidebar
258-
.getByRole('button', {
259-
name: 'New Workspace Item',
260-
})
261-
.first();
297+
this.workspaceSidebar = page.locator('[data-slot="sidebar-wrapper"]').first();
298+
this.workspaceContextMenuButton = page.getByRole('button', { name: 'New Workspace Item' });
262299
this.workspaceSettingsButton = page.getByRole('button', { name: 'Settings' });
263300
this.workspaceFileBrowserButton = page.getByRole('button', { name: 'Files' });
264301
this.workspaceCollaboratorInput = page.getByPlaceholder('Search collaborators or workspaces');

0 commit comments

Comments
 (0)