add table sorting to members, clients and tags table

This commit is contained in:
Gregor Vostrak
2026-02-18 17:41:36 +01:00
parent 88c0c334e9
commit cf98fabe0a
17 changed files with 829 additions and 46 deletions

View File

@@ -8,6 +8,7 @@ import {
createProjectViaApi,
createPublicProjectViaApi,
} from './utils/api';
import { getTableRowNames } from './utils/table';
async function goToClientsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
@@ -131,6 +132,96 @@ test('test that deleting a client via actions menu works', async ({ page, ctx })
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
});
// =============================================
// Sorting Tests
// =============================================
async function clearClientTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('client-table-state');
});
}
test('test that sorting clients by name and status works', async ({ page, ctx }) => {
await createClientViaApi(ctx, { name: 'AAA SortClient' });
await createClientViaApi(ctx, { name: 'ZZZ SortClient' });
await goToClientsOverview(page);
await clearClientTableState(page);
await page.reload();
const table = page.getByTestId('client_table');
await expect(table).toBeVisible();
// -- Name sorting (default is name asc) --
let names = await getTableRowNames(table);
expect(names.indexOf('AAA SortClient')).toBeLessThan(names.indexOf('ZZZ SortClient'));
const nameHeader = table.getByText('Name').first();
await nameHeader.click(); // toggle to desc
names = await getTableRowNames(table);
expect(names.indexOf('ZZZ SortClient')).toBeLessThan(names.indexOf('AAA SortClient'));
// -- Status sorting --
const statusHeader = table.getByText('Status').first();
await statusHeader.click(); // asc
await expect(statusHeader.locator('svg')).toBeVisible();
await statusHeader.click(); // desc
await expect(statusHeader.locator('svg')).toBeVisible();
});
test('test that sorting clients by project count works', async ({ page, ctx }) => {
const clientWithMany = await createClientViaApi(ctx, { name: 'ManyProjects Client' });
const clientWithNone = await createClientViaApi(ctx, { name: 'NoProjects Client' });
// Create projects for the first client
await createProjectViaApi(ctx, { name: 'Proj1', client_id: clientWithMany.id });
await createProjectViaApi(ctx, { name: 'Proj2', client_id: clientWithMany.id });
await goToClientsOverview(page);
await clearClientTableState(page);
await page.reload();
const table = page.getByTestId('client_table');
await expect(table).toBeVisible();
// Click Projects header - first click should sort desc (most projects first)
const projectsHeader = table.getByText('Projects').first();
await projectsHeader.click();
await expect(projectsHeader.locator('svg')).toBeVisible();
let names = await getTableRowNames(table);
expect(names.indexOf('ManyProjects Client')).toBeLessThan(
names.indexOf('NoProjects Client')
);
// Second click toggles to asc (least projects first)
await projectsHeader.click();
names = await getTableRowNames(table);
expect(names.indexOf('NoProjects Client')).toBeLessThan(
names.indexOf('ManyProjects Client')
);
});
test('test that client sort state persists after page reload', async ({ page }) => {
await goToClientsOverview(page);
await clearClientTableState(page);
await page.reload();
const table = page.getByTestId('client_table');
await expect(table).toBeVisible();
const nameHeader = table.getByText('Name').first();
await nameHeader.click(); // toggle to desc
await expect(nameHeader.locator('svg')).toBeVisible();
await page.reload();
await expect(page.getByTestId('client_table')).toBeVisible();
await expect(
page.getByTestId('client_table').getByText('Name').first().locator('svg')
).toBeVisible();
});
// =============================================
// Employee Permission Tests
// =============================================

View File

@@ -5,7 +5,12 @@ import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import type { Page } from '@playwright/test';
import { inviteAndAcceptMember } from './utils/members';
import { createPlaceholderMemberViaImportApi } from './utils/api';
import {
createPlaceholderMemberViaImportApi,
getMembersViaApi,
updateMemberBillableRateViaApi,
} from './utils/api';
import { getTableRowNames } from './utils/table';
// Tests that invite + accept members need more time
test.describe.configure({ timeout: 45000 });
@@ -487,6 +492,125 @@ test('test that accepted invitation disappears from invitations tab', async ({ p
await expect(page.getByText(memberEmail)).not.toBeVisible();
});
// =============================================
// Sorting Tests
// =============================================
// Helper to clear localStorage before tests that check sorting
async function clearMemberTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('member-table-state');
});
}
test('test that sorting members by name, role, and status works', async ({ page, ctx }) => {
// Create two placeholder members with names that sort predictably around "John Doe"
await createPlaceholderMemberViaImportApi(ctx, 'AAA SortFirst');
await createPlaceholderMemberViaImportApi(ctx, 'ZZZ SortLast');
await goToMembersPage(page);
await clearMemberTableState(page);
await page.reload();
const table = page.getByTestId('member_table');
await expect(table).toBeVisible();
// -- Name sorting (default is already name asc after clearing state) --
const nameHeader = table.getByText('Name').first();
let names = await getTableRowNames(table);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
await nameHeader.click(); // toggle to desc
names = await getTableRowNames(table);
expect(names.indexOf('ZZZ SortLast')).toBeLessThan(names.indexOf('AAA SortFirst'));
// -- Role sorting --
const roleHeader = table.getByText('Role').first();
await roleHeader.click(); // asc: Owner(0) < Placeholder(4)
names = await getTableRowNames(table);
const ownerIdx = names.indexOf('John Doe');
const placeholderIdx = names.indexOf('AAA SortFirst');
expect(ownerIdx).toBeLessThan(placeholderIdx);
await roleHeader.click(); // desc: Placeholder first
names = await getTableRowNames(table);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
// -- Status sorting --
const statusHeader = table.getByText('Status').first();
await statusHeader.click(); // asc: Active(0) < Inactive(1)
names = await getTableRowNames(table);
expect(names.indexOf('John Doe')).toBeLessThan(names.indexOf('AAA SortFirst'));
await statusHeader.click(); // desc: Inactive first
names = await getTableRowNames(table);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
// -- Email: just verify sort indicator appears --
const emailHeader = table.getByText('Email').first();
await emailHeader.click();
await expect(emailHeader.locator('svg')).toBeVisible();
});
test('test that member sort state persists after page reload', async ({ page }) => {
await goToMembersPage(page);
await clearMemberTableState(page);
await page.reload();
const table = page.getByTestId('member_table');
await expect(table).toBeVisible();
// Click Role header twice to set descending sort
const roleHeader = table.getByText('Role').first();
await roleHeader.click();
await expect(roleHeader.locator('svg')).toBeVisible();
await roleHeader.click();
await expect(roleHeader.locator('svg')).toBeVisible();
// Reload the page
await page.reload();
// Verify the sort indicator is still visible on Role column
await expect(page.getByTestId('member_table')).toBeVisible();
await expect(
page.getByTestId('member_table').getByText('Role').first().locator('svg')
).toBeVisible();
});
test('test that sorting members by billable rate works', async ({ page, ctx }) => {
// Create two placeholder members and set different billable rates
await createPlaceholderMemberViaImportApi(ctx, 'HighRate Member');
await createPlaceholderMemberViaImportApi(ctx, 'LowRate Member');
const members = await getMembersViaApi(ctx);
const highRateMember = members.find((m) => m.name === 'HighRate Member');
const lowRateMember = members.find((m) => m.name === 'LowRate Member');
expect(highRateMember).toBeDefined();
expect(lowRateMember).toBeDefined();
await updateMemberBillableRateViaApi(ctx, highRateMember!.id, 20000);
await updateMemberBillableRateViaApi(ctx, lowRateMember!.id, 5000);
await goToMembersPage(page);
await clearMemberTableState(page);
await page.reload();
const table = page.getByTestId('member_table');
await expect(table).toBeVisible();
// First click = desc (highest first), null rates last
const billableHeader = table.getByText('Billable Rate').first();
await billableHeader.click();
await expect(billableHeader.locator('svg')).toBeVisible();
let names = await getTableRowNames(table);
expect(names.indexOf('HighRate Member')).toBeLessThan(names.indexOf('LowRate Member'));
// Second click = asc (lowest first), null rates still last
await billableHeader.click();
names = await getTableRowNames(table);
expect(names.indexOf('LowRate Member')).toBeLessThan(names.indexOf('HighRate Member'));
});
// =============================================
// Employee Permission Tests
// =============================================
@@ -522,7 +646,7 @@ test.describe('Employee Sidebar Navigation', () => {
});
// Member table is empty — no rows rendered (only headers)
await expect(employee.page.getByTestId('client_table').locator('[role="row"]')).toHaveCount(
await expect(employee.page.getByTestId('member_table').locator('[role="row"]')).toHaveCount(
0
);

View File

@@ -3,6 +3,7 @@ import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { createTagViaApi } from './utils/api';
import { getTableRowNames } from './utils/table';
async function goToTagsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/tags');
@@ -89,6 +90,57 @@ test('test that multiple tags can be created via API and displayed in the table'
await expect(page.getByTestId('tag_table')).toContainText(tagName2);
});
// =============================================
// Sorting Tests
// =============================================
async function clearTagTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('tag-table-state');
});
}
test('test that sorting tags by name works', async ({ page, ctx }) => {
await createTagViaApi(ctx, { name: 'AAA SortTag' });
await createTagViaApi(ctx, { name: 'ZZZ SortTag' });
await goToTagsOverview(page);
await clearTagTableState(page);
await page.reload();
const table = page.getByTestId('tag_table');
await expect(table).toBeVisible();
// Default is name asc
let names = await getTableRowNames(table);
expect(names.indexOf('AAA SortTag')).toBeLessThan(names.indexOf('ZZZ SortTag'));
const nameHeader = table.getByText('Name').first();
await nameHeader.click(); // toggle to desc
names = await getTableRowNames(table);
expect(names.indexOf('ZZZ SortTag')).toBeLessThan(names.indexOf('AAA SortTag'));
});
test('test that tag sort state persists after page reload', async ({ page }) => {
await goToTagsOverview(page);
await clearTagTableState(page);
await page.reload();
const table = page.getByTestId('tag_table');
await expect(table).toBeVisible();
const nameHeader = table.getByText('Name').first();
await nameHeader.click(); // toggle to desc
await expect(nameHeader.locator('svg')).toBeVisible();
await page.reload();
await expect(page.getByTestId('tag_table')).toBeVisible();
await expect(
page.getByTestId('tag_table').getByText('Name').first().locator('svg')
).toBeVisible();
});
// =============================================
// Employee Permission Tests
// =============================================

View File

@@ -345,6 +345,36 @@ export async function createProjectMemberViaApi(
return body.data as { id: string; billable_rate: number | null };
}
export async function getMembersViaApi(ctx: TestContext) {
const response = await ctx.request.get(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/members`
);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data as Array<{
id: string;
name: string;
email: string;
role: string;
billable_rate: number | null;
is_placeholder: boolean;
}>;
}
export async function updateMemberBillableRateViaApi(
ctx: TestContext,
memberId: string,
billableRate: number | null
) {
const response = await ctx.request.put(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/members/${memberId}`,
{ data: { billable_rate: billableRate } }
);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data;
}
// ──────────────────────────────────────────────────
// Composite helpers (matching existing UI helper signatures)
// ──────────────────────────────────────────────────

View File

@@ -2,17 +2,102 @@
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { UserCircleIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { type Component, ref } from 'vue';
import { type Component, computed, ref } from 'vue';
import { type Client } from '@/packages/api/src';
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';
import { canCreateClients } from '@/utils/permissions';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import {
useVueTable,
getCoreRowModel,
getSortedRowModel,
type SortingState,
} from '@tanstack/vue-table';
defineProps<{
export type SortColumn = 'name' | 'projects_count' | 'status';
export type SortDirection = 'asc' | 'desc';
const props = defineProps<{
clients: Client[];
sortColumn: SortColumn;
sortDirection: SortDirection;
}>();
const emit = defineEmits<{
sort: [column: SortColumn, direction: SortDirection];
}>();
const createClient = ref(false);
const { projects } = useProjectsQuery();
const projectCountMap = computed(() => {
const map = new Map<string, number>();
projects.value.forEach((project) => {
if (project.client_id) {
map.set(project.client_id, (map.get(project.client_id) ?? 0) + 1);
}
});
return map;
});
const sorting = computed<SortingState>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
]);
const columns = computed(() => [
{
id: 'name',
accessorFn: (row: Client) => row.name.toLowerCase(),
},
{
id: 'projects_count',
sortDescFirst: true,
accessorFn: (row: Client) => projectCountMap.value.get(row.id) ?? 0,
},
{
id: 'status',
accessorFn: (row: Client) => (row.is_archived ? 1 : 0),
},
]);
const descFirstColumns = new Set<SortColumn>(
columns.value.filter((c) => 'sortDescFirst' in c && c.sortDescFirst).map((c) => c.id as SortColumn)
);
function handleSort(column: SortColumn) {
if (props.sortColumn === column) {
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
} else {
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
}
}
const table = useVueTable({
get data() {
return props.clients;
},
get columns() {
return columns.value;
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting.value;
},
},
manualSorting: false,
});
const sortedClients = computed(() => {
return table.getRowModel().rows.map((row) => row.original);
});
</script>
<template>
@@ -23,8 +108,12 @@ const createClient = ref(false);
data-testid="client_table"
class="grid min-w-full"
style="grid-template-columns: 1fr 150px 200px 80px">
<ClientTableHeading></ClientTableHeading>
<div v-if="clients.length === 0" class="col-span-3 py-24 text-center">
<ClientTableHeading
:sort-column="props.sortColumn"
:sort-direction="props.sortDirection"
:desc-first-columns="descFirstColumns"
@sort="handleSort"></ClientTableHeading>
<div v-if="sortedClients.length === 0" class="col-span-3 py-24 text-center">
<UserCircleIcon class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
<h3 class="text-text-primary font-semibold">No clients found</h3>
<p v-if="canCreateClients()" class="pb-5">Create your first client now!</p>
@@ -35,7 +124,7 @@ const createClient = ref(false);
>Create your First Client
</SecondaryButton>
</div>
<template v-for="client in clients" :key="client.id">
<template v-for="client in sortedClients" :key="client.id">
<ClientTableRow :client="client"></ClientTableRow>
</template>
</div>

View File

@@ -1,18 +1,67 @@
<script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue';
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { SortColumn, SortDirection } from '@/Components/Common/Client/ClientTable.vue';
const props = defineProps<{
sortColumn: SortColumn;
sortDirection: SortDirection;
descFirstColumns: ReadonlySet<SortColumn>;
}>();
const emit = defineEmits<{
sort: [column: SortColumn];
}>();
function handleSort(column: SortColumn) {
emit('sort', column);
}
function isSorted(column: SortColumn): boolean {
return props.sortColumn === column;
}
function isChevronDown(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return props.descFirstColumns.has(column)
? props.sortDirection === 'desc'
: props.sortDirection === 'asc';
}
function isChevronUp(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return !isChevronDown(column);
}
</script>
<template>
<TableHeading>
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<div
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('name')">
Name
<ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('projects_count')">
Projects
<ChevronDownIcon v-if="isChevronDown('projects_count')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('projects_count')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('status')">
Status
<ChevronDownIcon v-if="isChevronDown('status')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary"></div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>
</div>
</TableHeading>
</template>
<style scoped></style>

View File

@@ -36,13 +36,13 @@ const showEditModal = ref(false);
<TableRow>
<ClientEditModal v-model:show="showEditModal" :client="client"></ClientEditModal>
<div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="whitespace-nowrap flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span>
{{ client.name }}
</span>
</div>
<div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="whitespace-nowrap flex items-center px-3 py-4 text-sm font-medium text-text-primary">
<span class="text-text-secondary"> {{ projectCount }} Projects </span>
</div>
<div

View File

@@ -2,19 +2,117 @@
import MemberTableHeading from '@/Components/Common/Member/MemberTableHeading.vue';
import MemberTableRow from '@/Components/Common/Member/MemberTableRow.vue';
import { useMembersQuery } from '@/utils/useMembersQuery';
import type { Member } from '@/packages/api/src';
import { computed } from 'vue';
import {
useVueTable,
getCoreRowModel,
getSortedRowModel,
type SortingState,
} from '@tanstack/vue-table';
export type SortColumn = 'name' | 'email' | 'role' | 'billable_rate' | 'status';
export type SortDirection = 'asc' | 'desc';
const props = defineProps<{
sortColumn: SortColumn;
sortDirection: SortDirection;
}>();
const emit = defineEmits<{
sort: [column: SortColumn, direction: SortDirection];
}>();
const { members } = useMembersQuery();
const roleOrder: Record<string, number> = {
owner: 0,
admin: 1,
manager: 2,
employee: 3,
placeholder: 4,
};
const sorting = computed<SortingState>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
]);
const columns = [
{
id: 'name',
accessorFn: (row: Member) => row.name.toLowerCase(),
},
{
id: 'email',
accessorFn: (row: Member) => row.email.toLowerCase(),
},
{
id: 'role',
accessorFn: (row: Member) => roleOrder[row.role] ?? 99,
},
{
id: 'billable_rate',
sortDescFirst: true,
sortUndefined: 'last' as const,
accessorFn: (row: Member) => {
if (row.billable_rate === null) return undefined;
return row.billable_rate;
},
},
{
id: 'status',
accessorFn: (row: Member) => (row.is_placeholder ? 1 : 0),
},
];
const descFirstColumns = new Set<SortColumn>(
columns.filter((c) => c.sortDescFirst).map((c) => c.id as SortColumn)
);
function handleSort(column: SortColumn) {
if (props.sortColumn === column) {
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
} else {
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
}
}
const table = useVueTable({
get data() {
return members.value;
},
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting.value;
},
},
manualSorting: false,
});
const sortedMembers = computed(() => {
return table.getRowModel().rows.map((row) => row.original);
});
</script>
<template>
<div class="flow-root max-w-[100vw] overflow-x-auto">
<div class="inline-block min-w-full align-middle">
<div
data-testid="client_table"
data-testid="member_table"
class="grid min-w-full"
style="grid-template-columns: 1fr 1fr 180px 180px 150px 130px">
<MemberTableHeading></MemberTableHeading>
<template v-for="member in members" :key="member.id">
<MemberTableHeading
:sort-column="props.sortColumn"
:sort-direction="props.sortDirection"
:desc-first-columns="descFirstColumns"
@sort="handleSort"></MemberTableHeading>
<template v-for="member in sortedMembers" :key="member.id">
<MemberTableRow :member="member"></MemberTableRow>
</template>
</div>

View File

@@ -1,20 +1,83 @@
<script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue';
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';
const props = defineProps<{
sortColumn: SortColumn;
sortDirection: SortDirection;
descFirstColumns: ReadonlySet<SortColumn>;
}>();
const emit = defineEmits<{
sort: [column: SortColumn];
}>();
function handleSort(column: SortColumn) {
emit('sort', column);
}
function isSorted(column: SortColumn): boolean {
return props.sortColumn === column;
}
function isChevronDown(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return props.descFirstColumns.has(column)
? props.sortDirection === 'desc'
: props.sortDirection === 'asc';
}
function isChevronUp(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return !isChevronDown(column);
}
</script>
<template>
<TableHeading>
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<div
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('name')">
Name
<ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('email')">
Email
<ChevronDownIcon v-if="isChevronDown('email')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('email')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('role')">
Role
<ChevronDownIcon v-if="isChevronDown('role')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('role')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('billable_rate')">
Billable Rate
<ChevronDownIcon v-if="isChevronDown('billable_rate')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('billable_rate')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('status')">
Status
<ChevronDownIcon v-if="isChevronDown('status')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Email</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Billable Rate</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
<span class="sr-only">Edit</span>
</div>
</TableHeading>
</template>
<style scoped></style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Member, Organization } from '@/packages/api/src';
import { api } from '@/packages/api/src';
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/20/solid';
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/24/outline';
import MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue';
import TableRow from '@/Components/TableRow.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
@@ -82,11 +82,15 @@ const userHasValidMailAddress = computed(() => {
}}
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium">
<CheckCircleIcon v-if="member.is_placeholder === false" class="w-5"></CheckCircleIcon>
<span v-if="member.is_placeholder === false">Active</span>
<UserCircleIcon v-if="member.is_placeholder === true" class="w-5"></UserCircleIcon>
<span v-if="member.is_placeholder === true">Inactive</span>
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="member.is_placeholder === false">
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</span>
</template>
<template v-else>
<UserCircleIcon class="w-4 text-icon-default"></UserCircleIcon>
<span>Inactive</span>
</template>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">

View File

@@ -62,7 +62,7 @@ const sorting = computed<SortingState>(() => [
// Define column accessors for sorting.
// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,
// while text columns default to ascending (A-Z) on first click (chevron down).
const columns = [
const columns = computed(() => [
{
id: 'name',
accessorFn: (row: Project) => row.name.toLowerCase(),
@@ -98,11 +98,11 @@ const columns = [
id: 'status',
accessorFn: (row: Project) => (row.is_archived ? 1 : 0),
},
];
]);
// Columns with sortDescFirst get desc as default direction on first click.
const descFirstColumns = new Set<SortColumn>(
columns.filter((c) => c.sortDescFirst).map((c) => c.id as SortColumn)
columns.value.filter((c) => c.sortDescFirst).map((c) => c.id as SortColumn)
);
function handleSort(column: SortColumn) {
@@ -117,7 +117,9 @@ const table = useVueTable({
get data() {
return props.projects;
},
columns,
get columns() {
return columns.value;
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {

View File

@@ -2,18 +2,80 @@
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { FolderPlusIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useTagsQuery } from '@/utils/useTagsQuery';
import TagTableRow from '@/Components/Common/Tag/TagTableRow.vue';
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
import TagTableHeading from '@/Components/Common/Tag/TagTableHeading.vue';
import { canCreateTags } from '@/utils/permissions';
import type { Tag } from '@/packages/api/src';
defineProps<{
import {
useVueTable,
getCoreRowModel,
getSortedRowModel,
type SortingState,
} from '@tanstack/vue-table';
export type SortColumn = 'name';
export type SortDirection = 'asc' | 'desc';
const props = defineProps<{
createTag: (name: string) => Promise<Tag | undefined>;
sortColumn: SortColumn;
sortDirection: SortDirection;
}>();
const emit = defineEmits<{
sort: [column: SortColumn, direction: SortDirection];
}>();
const { tags } = useTagsQuery();
const showCreateTagModal = ref(false);
const sorting = computed<SortingState>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
]);
const columns = [
{
id: 'name',
accessorFn: (row: Tag) => row.name.toLowerCase(),
},
];
const descFirstColumns = new Set<SortColumn>(
columns.filter((c) => 'sortDescFirst' in c && c.sortDescFirst).map((c) => c.id as SortColumn)
);
function handleSort(column: SortColumn) {
if (props.sortColumn === column) {
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
} else {
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
}
}
const table = useVueTable({
get data() {
return tags.value;
},
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting.value;
},
},
manualSorting: false,
});
const sortedTags = computed(() => {
return table.getRowModel().rows.map((row) => row.original);
});
</script>
<template>
@@ -24,8 +86,12 @@ const showCreateTagModal = ref(false);
data-testid="tag_table"
class="grid min-w-full"
style="grid-template-columns: 1fr 80px">
<TagTableHeading></TagTableHeading>
<div v-if="tags.length === 0" class="col-span-5 py-24 text-center">
<TagTableHeading
:sort-column="props.sortColumn"
:sort-direction="props.sortDirection"
:desc-first-columns="descFirstColumns"
@sort="handleSort"></TagTableHeading>
<div v-if="sortedTags.length === 0" class="col-span-5 py-24 text-center">
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-text-primary font-semibold">No tags found</h3>
<p v-if="canCreateTags()" class="pb-5">Create your first tag now!</p>
@@ -36,7 +102,7 @@ const showCreateTagModal = ref(false);
>Create your First Tag</SecondaryButton
>
</div>
<template v-for="tag in tags" :key="tag.id">
<template v-for="tag in sortedTags" :key="tag.id">
<TagTableRow :tag="tag"></TagTableRow>
</template>
</div>

View File

@@ -1,16 +1,51 @@
<script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue';
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { SortColumn, SortDirection } from '@/Components/Common/Tag/TagTable.vue';
const props = defineProps<{
sortColumn: SortColumn;
sortDirection: SortDirection;
descFirstColumns: ReadonlySet<SortColumn>;
}>();
const emit = defineEmits<{
sort: [column: SortColumn];
}>();
function handleSort(column: SortColumn) {
emit('sort', column);
}
function isSorted(column: SortColumn): boolean {
return props.sortColumn === column;
}
function isChevronDown(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return props.descFirstColumns.has(column)
? props.sortDirection === 'desc'
: props.sortDirection === 'asc';
}
function isChevronUp(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return !isChevronDown(column);
}
</script>
<template>
<TableHeading>
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<div
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('name')">
Name
<ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>
</div>
</TableHeading>
</template>
<style scoped></style>

View File

@@ -12,6 +12,8 @@ import PageTitle from '@/Components/Common/PageTitle.vue';
import { canCreateClients } from '@/utils/permissions';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import { useStorage } from '@vueuse/core';
import type { SortColumn, SortDirection } from '@/Components/Common/Client/ClientTable.vue';
const { clients } = useClientsQuery();
@@ -19,6 +21,26 @@ const activeTab = ref<'active' | 'archived'>('active');
const createClient = ref(false);
interface ClientTableState {
sortColumn: SortColumn;
sortDirection: SortDirection;
}
const tableState = useStorage<ClientTableState>(
'client-table-state',
{
sortColumn: 'name',
sortDirection: 'asc',
},
undefined,
{ mergeDefaults: true }
);
function handleSort(column: SortColumn, direction: SortDirection) {
tableState.value.sortColumn = column;
tableState.value.sortDirection = direction;
}
const shownClients = computed(() => {
return clients.value.filter((client) => {
if (activeTab.value === 'active') {
@@ -45,6 +67,10 @@ const shownClients = computed(() => {
>
<ClientCreateModal v-model:show="createClient"></ClientCreateModal>
</MainContainer>
<ClientTable :clients="shownClients"></ClientTable>
<ClientTable
:clients="shownClients"
:sort-column="tableState.sortColumn"
:sort-direction="tableState.sortDirection"
@sort="handleSort"></ClientTable>
</AppLayout>
</template>

View File

@@ -13,6 +13,8 @@ import type { Role } from '@/types/jetstream';
import PageTitle from '@/Components/Common/PageTitle.vue';
import InvitationTable from '@/Components/Common/Invitation/InvitationTable.vue';
import { canCreateInvitations } from '@/utils/permissions';
import { useStorage } from '@vueuse/core';
import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';
const inviteMember = ref(false);
@@ -21,6 +23,26 @@ defineProps<{
}>();
const activeTab = ref<'all' | 'invitations'>('all');
interface MemberTableState {
sortColumn: SortColumn;
sortDirection: SortDirection;
}
const tableState = useStorage<MemberTableState>(
'member-table-state',
{
sortColumn: 'name',
sortDirection: 'asc',
},
undefined,
{ mergeDefaults: true }
);
function handleSort(column: SortColumn, direction: SortDirection) {
tableState.value.sortColumn = column;
tableState.value.sortDirection = direction;
}
</script>
<template>
@@ -45,7 +67,11 @@ const activeTab = ref<'all' | 'invitations'>('all');
:available-roles="availableRoles"
@close="activeTab = 'invitations'"></MemberInviteModal>
</MainContainer>
<MemberTable v-if="activeTab === 'all'"></MemberTable>
<MemberTable
v-if="activeTab === 'all'"
:sort-column="tableState.sortColumn"
:sort-direction="tableState.sortDirection"
@sort="handleSort"></MemberTable>
<InvitationTable v-if="activeTab === 'invitations'"></InvitationTable>
</AppLayout>
</template>

View File

@@ -147,7 +147,9 @@ const showBillableRate = computed(() => {
data-testid="status-filter-badge"
:value="tableState.filters.status"
@remove="removeStatusFilter"
@update:value="tableState.filters.status = $event as 'active' | 'archived' | 'all'" />
@update:value="
tableState.filters.status = $event as 'active' | 'archived' | 'all'
" />
<ProjectClientFilterBadge
v-if="tableState.filters.clientIds.length > 0"

View File

@@ -9,9 +9,31 @@ import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
import PageTitle from '@/Components/Common/PageTitle.vue';
import { canCreateTags } from '@/utils/permissions';
import { useTagsStore } from '@/utils/useTags';
import { useStorage } from '@vueuse/core';
import type { SortColumn, SortDirection } from '@/Components/Common/Tag/TagTable.vue';
const showCreateTagModal = ref(false);
interface TagTableState {
sortColumn: SortColumn;
sortDirection: SortDirection;
}
const tableState = useStorage<TagTableState>(
'tag-table-state',
{
sortColumn: 'name',
sortDirection: 'asc',
},
undefined,
{ mergeDefaults: true }
);
function handleSort(column: SortColumn, direction: SortDirection) {
tableState.value.sortColumn = column;
tableState.value.sortDirection = direction;
}
async function createTag(tag: string) {
return await useTagsStore().createTag(tag);
}
@@ -34,6 +56,10 @@ async function createTag(tag: string) {
v-model:show="showCreateTagModal"
:create-tag="createTag"></TagCreateModal>
</MainContainer>
<TagTable :create-tag="createTag"></TagTable>
<TagTable
:create-tag="createTag"
:sort-column="tableState.sortColumn"
:sort-direction="tableState.sortDirection"
@sort="handleSort"></TagTable>
</AppLayout>
</template>