Skip to content
Open
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
210 changes: 210 additions & 0 deletions cypress/e2e/api-sessions-sort.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
describe('Sessions sorting API tests', () => {
Cypress.session.clearAllSavedSessions();

let websiteId;

before(() => {
cy.login(Cypress.env('umami_user'), Cypress.env('umami_password'));
cy.fixture('websites').then(data => {
cy.request({
method: 'POST',
url: '/api/websites',
headers: {
'Content-Type': 'application/json',
Authorization: Cypress.env('authorization'),
},
body: data.websiteCreate,
}).then(response => {
websiteId = response.body.id;
expect(response.status).to.eq(200);
});
});
});

it('Returns sessions with default sort (no sort params).', () => {
const now = Date.now();
const oneDayAgo = now - 86400000;

cy.request({
method: 'GET',
url: `/api/websites/${websiteId}/sessions`,
headers: {
Authorization: Cypress.env('authorization'),
},
qs: {
startAt: oneDayAgo,
endAt: now,
},
}).then(response => {
expect(response.status).to.eq(200);
expect(response.body).to.have.property('data');
expect(response.body).to.have.property('count');
expect(response.body).to.have.property('page');
expect(response.body).to.have.property('pageSize');
});
});

it('Accepts orderBy=visits with sortDescending=true.', () => {
const now = Date.now();
const oneDayAgo = now - 86400000;

cy.request({
method: 'GET',
url: `/api/websites/${websiteId}/sessions`,
headers: {
Authorization: Cypress.env('authorization'),
},
qs: {
startAt: oneDayAgo,
endAt: now,
orderBy: 'visits',
sortDescending: 'true',
},
}).then(response => {
expect(response.status).to.eq(200);
expect(response.body).to.have.property('data');
expect(response.body).to.have.property('orderBy');
});
});

it('Accepts orderBy=views with sortDescending=false.', () => {
const now = Date.now();
const oneDayAgo = now - 86400000;

cy.request({
method: 'GET',
url: `/api/websites/${websiteId}/sessions`,
headers: {
Authorization: Cypress.env('authorization'),
},
qs: {
startAt: oneDayAgo,
endAt: now,
orderBy: 'views',
sortDescending: 'false',
},
}).then(response => {
expect(response.status).to.eq(200);
expect(response.body).to.have.property('data');
});
});

it('Accepts orderBy=createdAt.', () => {
const now = Date.now();
const oneDayAgo = now - 86400000;

cy.request({
method: 'GET',
url: `/api/websites/${websiteId}/sessions`,
headers: {
Authorization: Cypress.env('authorization'),
},
qs: {
startAt: oneDayAgo,
endAt: now,
orderBy: 'createdAt',
sortDescending: 'true',
},
}).then(response => {
expect(response.status).to.eq(200);
expect(response.body).to.have.property('data');
});
});

it('Accepts orderBy=firstAt.', () => {
const now = Date.now();
const oneDayAgo = now - 86400000;

cy.request({
method: 'GET',
url: `/api/websites/${websiteId}/sessions`,
headers: {
Authorization: Cypress.env('authorization'),
},
qs: {
startAt: oneDayAgo,
endAt: now,
orderBy: 'firstAt',
sortDescending: 'false',
},
}).then(response => {
expect(response.status).to.eq(200);
expect(response.body).to.have.property('data');
});
});

it('Falls back gracefully with invalid orderBy value.', () => {
const now = Date.now();
const oneDayAgo = now - 86400000;

cy.request({
method: 'GET',
url: `/api/websites/${websiteId}/sessions`,
headers: {
Authorization: Cypress.env('authorization'),
},
qs: {
startAt: oneDayAgo,
endAt: now,
orderBy: 'invalid_column',
sortDescending: 'true',
},
}).then(response => {
expect(response.status).to.eq(200);
expect(response.body).to.have.property('data');
});
});

it('Works with sorting combined with search.', () => {
const now = Date.now();
const oneDayAgo = now - 86400000;

cy.request({
method: 'GET',
url: `/api/websites/${websiteId}/sessions`,
headers: {
Authorization: Cypress.env('authorization'),
},
qs: {
startAt: oneDayAgo,
endAt: now,
orderBy: 'views',
sortDescending: 'true',
search: 'Chrome',
},
}).then(response => {
expect(response.status).to.eq(200);
expect(response.body).to.have.property('data');
});
});

it('Works with sorting combined with pagination.', () => {
const now = Date.now();
const oneDayAgo = now - 86400000;

cy.request({
method: 'GET',
url: `/api/websites/${websiteId}/sessions`,
headers: {
Authorization: Cypress.env('authorization'),
},
qs: {
startAt: oneDayAgo,
endAt: now,
orderBy: 'visits',
sortDescending: 'true',
page: 1,
pageSize: 5,
},
}).then(response => {
expect(response.status).to.eq(200);
expect(response.body).to.have.property('data');
expect(response.body).to.have.property('page', 1);
expect(response.body).to.have.property('pageSize', 5);
});
});

after(() => {
cy.deleteWebsite(websiteId);
});
});
74 changes: 74 additions & 0 deletions cypress/e2e/sessions-sort.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
describe('Sessions sorting UI tests', () => {
Cypress.session.clearAllSavedSessions();

let websiteId;

before(() => {
cy.login(Cypress.env('umami_user'), Cypress.env('umami_password'));
cy.request({
method: 'POST',
url: '/api/websites',
headers: {
'Content-Type': 'application/json',
Authorization: Cypress.env('authorization'),
},
body: { name: 'Sort Test Site', domain: 'sorttest.com' },
}).then(response => {
websiteId = response.body.id;
expect(response.status).to.eq(200);
});
});

beforeEach(() => {
cy.login(Cypress.env('umami_user'), Cypress.env('umami_password'));
cy.visit(`/websites/${websiteId}/sessions`);
});

it('Renders sortable column headers for visits, views, and last seen.', () => {
cy.getDataTest('sort-visits').should('exist').and('be.visible');
cy.getDataTest('sort-views').should('exist').and('be.visible');
cy.getDataTest('sort-createdAt').should('exist').and('be.visible');
});

it('Clicking visits header adds orderBy and sortDescending to URL.', () => {
cy.getDataTest('sort-visits').click();
cy.url().should('include', 'orderBy=visits');
cy.url().should('include', 'sortDescending=true');
});

it('Clicking visits header twice toggles to ascending sort.', () => {
cy.getDataTest('sort-visits').click();
cy.url().should('include', 'sortDescending=true');

cy.getDataTest('sort-visits').click();
cy.url().should('include', 'orderBy=visits');
cy.url().should('include', 'sortDescending=false');
});

it('Clicking visits header three times clears sort params.', () => {
cy.getDataTest('sort-visits').click();
cy.getDataTest('sort-visits').click();
cy.getDataTest('sort-visits').click();

cy.url().should('not.include', 'orderBy');
cy.url().should('not.include', 'sortDescending');
});

it('Clicking a different column switches sort target.', () => {
cy.getDataTest('sort-visits').click();
cy.url().should('include', 'orderBy=visits');

cy.getDataTest('sort-views').click();
cy.url().should('include', 'orderBy=views');
cy.url().should('include', 'sortDescending=true');
});

it('Resets to page 1 when sort changes.', () => {
cy.getDataTest('sort-visits').click();
cy.url().should('include', 'page=1');
});

after(() => {
cy.deleteWebsite(websiteId);
});
});
49 changes: 35 additions & 14 deletions src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { DataColumn, DataTable, type DataTableProps } from '@umami/react-zen';
import Link from 'next/link';
import { Avatar } from '@/components/common/Avatar';
import { DateDistance } from '@/components/common/DateDistance';
import { TypeIcon } from '@/components/common/TypeIcon';
import { useFormat, useMessages, useNavigation } from '@/components/hooks';
import { DataColumn, DataTable, type DataTableProps } from "@umami/react-zen";
import Link from "next/link";
import { Avatar } from "@/components/common/Avatar";
import { DateDistance } from "@/components/common/DateDistance";
import { SortableLabel } from "@/components/common/SortableLabel";
import { TypeIcon } from "@/components/common/TypeIcon";
import { useFormat, useMessages, useNavigation } from "@/components/hooks";

export function SessionsTable(props: DataTableProps) {
const { formatMessage, labels } = useMessages();
Expand All @@ -19,39 +20,59 @@ export function SessionsTable(props: DataTableProps) {
</Link>
)}
</DataColumn>
<DataColumn id="visits" label={formatMessage(labels.visits)} width="80px" />
<DataColumn id="views" label={formatMessage(labels.views)} width="80px" />
<DataColumn
id="visits"
label={
<SortableLabel column="visits" label={formatMessage(labels.visits)} />
}
width="100px"
/>
<DataColumn
id="views"
label={
<SortableLabel column="views" label={formatMessage(labels.views)} />
}
width="100px"
/>
<DataColumn id="country" label={formatMessage(labels.country)}>
{(row: any) => (
<TypeIcon type="country" value={row.country}>
{formatValue(row.country, 'country')}
{formatValue(row.country, "country")}
</TypeIcon>
)}
</DataColumn>
<DataColumn id="city" label={formatMessage(labels.city)} />
<DataColumn id="browser" label={formatMessage(labels.browser)}>
{(row: any) => (
<TypeIcon type="browser" value={row.browser}>
{formatValue(row.browser, 'browser')}
{formatValue(row.browser, "browser")}
</TypeIcon>
)}
</DataColumn>
<DataColumn id="os" label={formatMessage(labels.os)}>
{(row: any) => (
<TypeIcon type="os" value={row.os}>
{formatValue(row.os, 'os')}
{formatValue(row.os, "os")}
</TypeIcon>
)}
</DataColumn>
<DataColumn id="device" label={formatMessage(labels.device)}>
{(row: any) => (
<TypeIcon type="device" value={row.device}>
{formatValue(row.device, 'device')}
{formatValue(row.device, "device")}
</TypeIcon>
)}
</DataColumn>
<DataColumn id="lastAt" label={formatMessage(labels.lastSeen)}>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
<DataColumn
id="lastAt"
label={
<SortableLabel
column="lastAt"
label={formatMessage(labels.lastSeen)}
/>
}
>
{(row: any) => <DateDistance date={new Date(row.lastAt)} />}
</DataColumn>
Comment thread
superham marked this conversation as resolved.
</DataTable>
);
Expand Down
3 changes: 2 additions & 1 deletion src/app/api/websites/[websiteId]/sessions/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { dateRangeParams, filterParams, pagingParams, searchParams } from '@/lib/schema';
import { dateRangeParams, filterParams, pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canViewWebsite } from '@/permissions';
import { getWebsiteSessions } from '@/queries/sql';

Expand All @@ -14,6 +14,7 @@ export async function GET(
...filterParams,
...pagingParams,
...searchParams,
...sortingParams,
});

const { auth, query, error } = await parseRequest(request, schema);
Expand Down
Loading