feat(FN-795): add mobile responsive layout for Activity Log modal
- Add mobile responsive CSS for Activity Log modal with adaptive breakpoints - Add regression test coverage for mobile activity log layout - Add tests for ActivityLogModal responsive behavior - Update README with responsive layout documentation note
This commit is contained in:
@@ -236,6 +236,7 @@ View a centralized timeline of all task lifecycle events. Click the history icon
|
||||
- **Auto-refresh**: Log updates automatically every 30 seconds when the modal is open
|
||||
- **Pagination**: "Load More" button fetches older entries (100 entries per request, max 1000)
|
||||
- **Clear Log**: Maintenance function to clear all activity history (with confirmation)
|
||||
- **Responsive Layout**: On narrow screens (≤768px), the modal adapts with a stacked header, full-width filter controls, wrapped active-filters bar, reflowed entry text, and vertically stacked confirmation actions — preserving access to filters, task links, and clear-log on mobile devices
|
||||
|
||||
**Event Metadata**:
|
||||
- Task moves show from/to column transitions
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
/**
|
||||
* Stylesheet regression test for Activity Log mobile layout.
|
||||
*
|
||||
* Parses `packages/dashboard/app/styles.css` and asserts that an
|
||||
* `@media (max-width: 768px)` block contains Activity Log mobile rules
|
||||
* for stacked/wrapped controls and entry layout. These selectors must
|
||||
* remain inside a mobile media query so the Activity Log renders
|
||||
* correctly on narrow screens.
|
||||
*/
|
||||
|
||||
describe("activity-log-mobile-layout.css", () => {
|
||||
const cssPath = resolve(__dirname, "../styles.css");
|
||||
const cssContent = readFileSync(cssPath, "utf-8");
|
||||
|
||||
/** Extract all content inside @media (max-width: 768px) blocks. */
|
||||
function extractMobileMediaBlocks(content: string): string {
|
||||
const blocks: string[] = [];
|
||||
const regex = /@media\s*\(\s*max-width:\s*768px\s*\)\s*\{/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const startIdx = match.index + match[0].length;
|
||||
let braceCount = 1;
|
||||
let endIdx = startIdx;
|
||||
while (braceCount > 0 && endIdx < content.length) {
|
||||
if (content[endIdx] === "{") braceCount++;
|
||||
if (content[endIdx] === "}") braceCount--;
|
||||
endIdx++;
|
||||
}
|
||||
if (braceCount === 0) {
|
||||
blocks.push(content.slice(startIdx, endIdx - 1));
|
||||
}
|
||||
}
|
||||
return blocks.join("\n");
|
||||
}
|
||||
|
||||
const mobileCss = extractMobileMediaBlocks(cssContent);
|
||||
|
||||
// ── Modal header / actions ──────────────────────────────────────────
|
||||
|
||||
it("has mobile rule for activity-log-header to wrap on narrow screens", () => {
|
||||
expect(mobileCss).toMatch(/\.activity-log-header\s*\{[^}]*flex-wrap:\s*wrap/);
|
||||
});
|
||||
|
||||
it("has mobile rule for activity-log-actions to wrap and fill full width", () => {
|
||||
expect(mobileCss).toMatch(/\.activity-log-actions\s*\{[^}]*flex-wrap:\s*wrap/);
|
||||
expect(mobileCss).toMatch(/\.activity-log-actions\s*\{[^}]*flex:\s*1\s+1\s+100%/);
|
||||
});
|
||||
|
||||
// ── Filter controls ─────────────────────────────────────────────────
|
||||
|
||||
it("has mobile rule for filter containers to fill available width", () => {
|
||||
expect(mobileCss).toMatch(/\.activity-log-filter/);
|
||||
expect(mobileCss).toMatch(/\.activity-log-filter--project/);
|
||||
});
|
||||
|
||||
it("has mobile rule for filter selects to fill width", () => {
|
||||
expect(mobileCss).toMatch(/\.activity-log-filter-select\s*\{[^}]*width:\s*100%/);
|
||||
});
|
||||
|
||||
// ── Active filters bar ──────────────────────────────────────────────
|
||||
|
||||
it("has mobile rule for active-filters bar to wrap", () => {
|
||||
expect(mobileCss).toMatch(/\.activity-log-active-filters\s*\{[^}]*flex-wrap:\s*wrap/);
|
||||
});
|
||||
|
||||
it("resets clear-filters margin-left on mobile so it doesn't force right", () => {
|
||||
expect(mobileCss).toMatch(/\.activity-log-clear-filters\s*\{[^}]*margin-left:\s*0/);
|
||||
});
|
||||
|
||||
// ── Entry layout ────────────────────────────────────────────────────
|
||||
|
||||
it("has mobile rule for entry details to wrap with word-break", () => {
|
||||
expect(mobileCss).toMatch(/\.activity-log-entry-details\s*\{[^}]*word-break:\s*break-word/);
|
||||
});
|
||||
|
||||
it("has mobile rule for entry text to break words", () => {
|
||||
expect(mobileCss).toMatch(/\.activity-log-entry-text\s*\{[^}]*word-break:\s*break-word/);
|
||||
});
|
||||
|
||||
it("has mobile rule for entry headers to wrap", () => {
|
||||
expect(mobileCss).toMatch(/\.activity-log-entry-header\s*\{[^}]*flex-wrap:\s*wrap/);
|
||||
});
|
||||
|
||||
// ── Confirmation dialog ─────────────────────────────────────────────
|
||||
|
||||
it("has mobile rule for confirm actions to stack vertically", () => {
|
||||
expect(mobileCss).toMatch(/\.activity-log-confirm-actions\s*\{[^}]*flex-direction:\s*column/);
|
||||
});
|
||||
|
||||
it("has mobile rule for confirm buttons to fill width", () => {
|
||||
expect(mobileCss).toMatch(/\.activity-log-confirm-cancel[^}]*width:\s*100%/);
|
||||
expect(mobileCss).toMatch(/\.activity-log-confirm-clear[^}]*width:\s*100%/);
|
||||
});
|
||||
});
|
||||
@@ -381,4 +381,107 @@ describe("ActivityLogModal", () => {
|
||||
expect(screen.getByText(/No activity matches the current filters/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Responsive Layout Regression Tests ───────────────────────────
|
||||
|
||||
it("renders all mobile-responsive CSS classes on the modal structure", async () => {
|
||||
const { container } = render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
tasks={mockTasks}
|
||||
onOpenTaskDetail={mockOnOpenTaskDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("activity-log-modal")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Verify key structural classes that the mobile CSS targets
|
||||
const modal = container.querySelector(".activity-log-modal");
|
||||
expect(modal).toBeTruthy();
|
||||
expect(modal!.querySelector(".activity-log-header")).toBeTruthy();
|
||||
expect(modal!.querySelector(".activity-log-title")).toBeTruthy();
|
||||
expect(modal!.querySelector(".activity-log-actions")).toBeTruthy();
|
||||
expect(modal!.querySelector(".activity-log-content")).toBeTruthy();
|
||||
expect(modal!.querySelector(".activity-log-list")).toBeTruthy();
|
||||
expect(modal!.querySelector(".activity-log-entry")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders entry header and details within each entry for mobile reflow", async () => {
|
||||
const { container } = render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
tasks={mockTasks}
|
||||
onOpenTaskDetail={mockOnOpenTaskDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId("activity-entry")).toHaveLength(3);
|
||||
});
|
||||
|
||||
// Each entry should have the inner structure that mobile CSS reflows
|
||||
const entries = container.querySelectorAll(".activity-log-entry");
|
||||
for (const entry of entries) {
|
||||
expect(entry.querySelector(".activity-log-entry-icon")).toBeTruthy();
|
||||
expect(entry.querySelector(".activity-log-entry-content")).toBeTruthy();
|
||||
expect(entry.querySelector(".activity-log-entry-header")).toBeTruthy();
|
||||
expect(entry.querySelector(".activity-log-entry-type")).toBeTruthy();
|
||||
expect(entry.querySelector(".activity-log-entry-time")).toBeTruthy();
|
||||
expect(entry.querySelector(".activity-log-entry-details")).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders active-filters bar with correct classes when filter is active", async () => {
|
||||
render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
tasks={mockTasks}
|
||||
onOpenTaskDetail={mockOnOpenTaskDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
// Apply a type filter to show the active-filters bar
|
||||
const filterSelect = await screen.findByTestId("activity-filter");
|
||||
fireEvent.change(filterSelect, { target: { value: "task:created" } });
|
||||
|
||||
await waitFor(() => {
|
||||
const activeFilters = document.querySelector(".activity-log-active-filters");
|
||||
expect(activeFilters).toBeTruthy();
|
||||
expect(activeFilters!.querySelector(".activity-log-filter-label")).toBeTruthy();
|
||||
expect(activeFilters!.querySelector(".activity-log-filter-badge")).toBeTruthy();
|
||||
expect(activeFilters!.querySelector(".activity-log-clear-filters")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders clear-confirmation dialog with stacked action classes", async () => {
|
||||
render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
tasks={mockTasks}
|
||||
onOpenTaskDetail={mockOnOpenTaskDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("activity-clear")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("activity-clear"));
|
||||
|
||||
// Confirm dialog has the structure that mobile CSS stacks
|
||||
const overlay = document.querySelector(".activity-log-confirm-overlay");
|
||||
expect(overlay).toBeTruthy();
|
||||
const dialog = overlay!.querySelector(".activity-log-confirm-dialog");
|
||||
expect(dialog).toBeTruthy();
|
||||
const actions = dialog!.querySelector(".activity-log-confirm-actions");
|
||||
expect(actions).toBeTruthy();
|
||||
expect(actions!.querySelector(".activity-log-confirm-cancel")).toBeTruthy();
|
||||
expect(actions!.querySelector(".activity-log-confirm-clear")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12273,6 +12273,111 @@ html .column.drag-over * {
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
/* ── Activity Log — Mobile (≤ 768px) ─────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Header: stack title above actions */
|
||||
.activity-log-header {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
}
|
||||
|
||||
.activity-log-title {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.activity-log-actions {
|
||||
flex: 1 1 100%;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* Filter containers fill available width */
|
||||
.activity-log-filter,
|
||||
.activity-log-filter--project {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.activity-log-filter-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Action buttons stay inline but shrink */
|
||||
.activity-log-refresh,
|
||||
.activity-log-clear,
|
||||
.activity-log-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Active filters bar wraps on small screens */
|
||||
.activity-log-active-filters {
|
||||
flex-wrap: wrap;
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.activity-log-clear-filters {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Content area tighter padding */
|
||||
.activity-log-content {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* Entries: allow content to wrap freely */
|
||||
.activity-log-entry {
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.activity-log-entry-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.activity-log-entry-header {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.activity-log-entry-details {
|
||||
flex-wrap: wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.activity-log-entry-text {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Empty state compact */
|
||||
.activity-log-empty {
|
||||
padding: var(--space-xl) var(--space-lg);
|
||||
}
|
||||
|
||||
/* Confirmation dialog fills more of the viewport */
|
||||
.activity-log-confirm-dialog {
|
||||
max-width: calc(100vw - 32px);
|
||||
margin: 0 var(--space-lg);
|
||||
}
|
||||
|
||||
.activity-log-confirm-actions {
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.activity-log-confirm-cancel,
|
||||
.activity-log-confirm-clear {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
GIT MANAGER MODAL
|
||||
══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
Reference in New Issue
Block a user