Add number of records for each board column (#122)

This commit is contained in:
Hakan Shehu
2025-07-09 07:45:07 +02:00
committed by GitHub
parent 4469301f38
commit d33be86637
6 changed files with 218 additions and 30 deletions

View File

@@ -19,10 +19,8 @@ export class RecordFieldValueCountQueryHandler
public async handleQuery( public async handleQuery(
input: RecordFieldValueCountQueryInput input: RecordFieldValueCountQueryInput
): Promise<RecordFieldValueCountQueryOutput> { ): Promise<RecordFieldValueCountQueryOutput> {
const counts = await this.fetchFieldValueCounts(input); const result = await this.fetchFieldValueCounts(input);
return { return result;
items: counts,
};
} }
public async checkForChanges( public async checkForChanges(
@@ -37,7 +35,7 @@ export class RecordFieldValueCountQueryHandler
) { ) {
return { return {
hasChanges: true, hasChanges: true,
result: { items: [] }, result: { values: [], nullCount: 0 },
}; };
} }
@@ -87,6 +85,16 @@ export class RecordFieldValueCountQueryHandler
event.accountId === input.accountId && event.accountId === input.accountId &&
event.workspaceId === input.workspaceId event.workspaceId === input.workspaceId
) { ) {
if (
event.node.type === 'database' &&
event.node.id === input.databaseId
) {
return {
hasChanges: true,
result: { values: [], nullCount: 0 },
};
}
if ( if (
event.node.type === 'record' && event.node.type === 'record' &&
event.node.attributes.databaseId === input.databaseId event.node.attributes.databaseId === input.databaseId
@@ -97,16 +105,6 @@ export class RecordFieldValueCountQueryHandler
result: newResult, result: newResult,
}; };
} }
if (
event.node.type === 'database' &&
event.node.id === input.databaseId
) {
return {
hasChanges: true,
result: { items: [] },
};
}
} }
return { return {
@@ -116,12 +114,15 @@ export class RecordFieldValueCountQueryHandler
private async fetchFieldValueCounts( private async fetchFieldValueCounts(
input: RecordFieldValueCountQueryInput input: RecordFieldValueCountQueryInput
): Promise<RecordFieldValueCount[]> { ): Promise<RecordFieldValueCountQueryOutput> {
const database = await this.fetchDatabase(input); const database = await this.fetchDatabase(input);
const field = database.attributes.fields[input.fieldId]; const field = database.attributes.fields[input.fieldId];
if (!field) { if (!field) {
return []; return {
values: [],
nullCount: 0,
};
} }
const filterQuery = buildFiltersQuery( const filterQuery = buildFiltersQuery(
@@ -137,7 +138,24 @@ export class RecordFieldValueCountQueryHandler
); );
const result = await workspace.database.executeQuery(query); const result = await workspace.database.executeQuery(query);
return result.rows;
const output: RecordFieldValueCountQueryOutput = {
values: [],
nullCount: 0,
};
for (const row of result.rows) {
if (row.value === 'null') {
output.nullCount = row.count;
} else {
output.values.push({
value: row.value,
count: row.count,
});
}
}
return output;
} }
private buildQuery( private buildQuery(
@@ -190,10 +208,23 @@ export class RecordFieldValueCountQueryHandler
FROM nodes n, FROM nodes n,
json_each(${this.buildFieldSelector(field)}) json_each(${this.buildFieldSelector(field)})
WHERE n.parent_id = '${databaseId}' WHERE n.parent_id = '${databaseId}'
AND n.type = 'record' AND n.type = 'record'
AND ${this.buildFieldSelector(field)} IS NOT NULL
${filterQuery} ${filterQuery}
GROUP BY json_each.value GROUP BY json_each.value
UNION ALL
SELECT
'null' as value,
COUNT(*) as count
FROM nodes n
WHERE n.parent_id = '${databaseId}'
AND n.type = 'record'
AND (${this.buildFieldSelector(field)} IS NULL
OR ${this.buildFieldSelector(field)} = '[]'
OR json_array_length(${this.buildFieldSelector(field)}) = 0)
${filterQuery}
ORDER BY count DESC, value ASC ORDER BY count DESC, value ASC
`; `;
} }
@@ -210,7 +241,6 @@ export class RecordFieldValueCountQueryHandler
FROM nodes n FROM nodes n
WHERE n.parent_id = '${databaseId}' WHERE n.parent_id = '${databaseId}'
AND n.type = 'record' AND n.type = 'record'
AND ${this.buildFieldSelector(field)} IS NOT NULL
${filterQuery} ${filterQuery}
GROUP BY value GROUP BY value
ORDER BY count DESC, value ASC ORDER BY count DESC, value ASC

View File

@@ -15,7 +15,8 @@ export type RecordFieldValueCount = {
}; };
export type RecordFieldValueCountQueryOutput = { export type RecordFieldValueCountQueryOutput = {
items: RecordFieldValueCount[]; values: RecordFieldValueCount[];
nullCount: number;
}; };
declare module '@colanode/client/queries' { declare module '@colanode/client/queries' {

View File

@@ -39,13 +39,14 @@ export const BoardViewColumnsCollaborator = ({
return null; return null;
} }
const collaborators = collaboratorCountQuery.data?.items ?? []; const collaborators = collaboratorCountQuery.data?.values ?? [];
const noValueFilter: DatabaseViewFilterAttributes = { const noValueFilter: DatabaseViewFilterAttributes = {
id: '1', id: '1',
type: 'field', type: 'field',
fieldId: field.id, fieldId: field.id,
operator: 'is_empty', operator: 'is_empty',
}; };
const noValueCount = collaboratorCountQuery.data?.nullCount ?? 0;
return ( return (
<> <>
@@ -75,6 +76,7 @@ export const BoardViewColumnsCollaborator = ({
<BoardViewColumnCollaboratorHeader <BoardViewColumnCollaboratorHeader
field={field} field={field}
collaborator={collaborator.value} collaborator={collaborator.value}
count={collaborator.count}
/> />
), ),
canDrag: (record) => record.canEdit, canDrag: (record) => record.canEdit,
@@ -145,6 +147,7 @@ export const BoardViewColumnsCollaborator = ({
<BoardViewColumnCollaboratorHeader <BoardViewColumnCollaboratorHeader
field={field} field={field}
collaborator={null} collaborator={null}
count={noValueCount}
/> />
), ),
canDrag: () => true, canDrag: () => true,
@@ -187,11 +190,13 @@ export const BoardViewColumnsCollaborator = ({
interface BoardViewColumnCollaboratorHeaderProps { interface BoardViewColumnCollaboratorHeaderProps {
field: CollaboratorFieldAttributes; field: CollaboratorFieldAttributes;
collaborator: string | null; collaborator: string | null;
count: number;
} }
const BoardViewColumnCollaboratorHeader = ({ const BoardViewColumnCollaboratorHeader = ({
field, field,
collaborator, collaborator,
count,
}: BoardViewColumnCollaboratorHeaderProps) => { }: BoardViewColumnCollaboratorHeaderProps) => {
const workspace = useWorkspace(); const workspace = useWorkspace();
@@ -212,6 +217,9 @@ const BoardViewColumnCollaboratorHeader = ({
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-2 items-center">
<CircleDashed className="size-5" /> <CircleDashed className="size-5" />
<p className="text-muted-foreground">No {field.name}</p> <p className="text-muted-foreground">No {field.name}</p>
<p className="text-muted-foreground text-sm ml-1">
{count.toLocaleString()}
</p>
</div> </div>
); );
} }
@@ -221,6 +229,9 @@ const BoardViewColumnCollaboratorHeader = ({
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-2 items-center">
<Spinner className="size-5" /> <Spinner className="size-5" />
<p className="text-muted-foreground">Loading...</p> <p className="text-muted-foreground">Loading...</p>
<p className="text-muted-foreground text-sm ml-1">
{count.toLocaleString()}
</p>
</div> </div>
); );
} }
@@ -231,6 +242,9 @@ const BoardViewColumnCollaboratorHeader = ({
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-2 items-center">
<CircleAlert className="size-5" /> <CircleAlert className="size-5" />
<p className="text-muted-foreground">Unknown</p> <p className="text-muted-foreground">Unknown</p>
<p className="text-muted-foreground text-sm ml-1">
{count.toLocaleString()}
</p>
</div> </div>
); );
} }
@@ -244,6 +258,9 @@ const BoardViewColumnCollaboratorHeader = ({
className="size-5" className="size-5"
/> />
<p>{user.name}</p> <p>{user.name}</p>
<p className="text-muted-foreground text-sm ml-1">
{count.toLocaleString()}
</p>
</div> </div>
); );
}; };

View File

@@ -37,7 +37,7 @@ export const BoardViewColumnsCreatedBy = ({
return null; return null;
} }
const users = createdByCountQuery.data?.items ?? []; const users = createdByCountQuery.data?.values ?? [];
return ( return (
<> <>
@@ -62,6 +62,7 @@ export const BoardViewColumnsCreatedBy = ({
<BoardViewColumnCreatedByHeader <BoardViewColumnCreatedByHeader
field={field} field={field}
createdBy={user.value} createdBy={user.value}
count={user.count}
/> />
), ),
canDrag: () => false, canDrag: () => false,
@@ -79,10 +80,12 @@ export const BoardViewColumnsCreatedBy = ({
interface BoardViewColumnCreatedByHeaderProps { interface BoardViewColumnCreatedByHeaderProps {
field: CreatedByFieldAttributes; field: CreatedByFieldAttributes;
createdBy: string; createdBy: string;
count: number;
} }
const BoardViewColumnCreatedByHeader = ({ const BoardViewColumnCreatedByHeader = ({
createdBy, createdBy,
count,
}: BoardViewColumnCreatedByHeaderProps) => { }: BoardViewColumnCreatedByHeaderProps) => {
const workspace = useWorkspace(); const workspace = useWorkspace();
@@ -98,6 +101,9 @@ const BoardViewColumnCreatedByHeader = ({
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-2 items-center">
<Spinner className="size-5" /> <Spinner className="size-5" />
<p className="text-muted-foreground">Loading...</p> <p className="text-muted-foreground">Loading...</p>
<p className="text-muted-foreground text-sm ml-1">
{count.toLocaleString()}
</p>
</div> </div>
); );
} }
@@ -108,6 +114,9 @@ const BoardViewColumnCreatedByHeader = ({
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-2 items-center">
<CircleAlert className="size-5" /> <CircleAlert className="size-5" />
<p className="text-muted-foreground">Unknown</p> <p className="text-muted-foreground">Unknown</p>
<p className="text-muted-foreground text-sm ml-1">
{count.toLocaleString()}
</p>
</div> </div>
); );
} }
@@ -121,6 +130,9 @@ const BoardViewColumnCreatedByHeader = ({
className="size-5" className="size-5"
/> />
<p>{user.name}</p> <p>{user.name}</p>
<p className="text-muted-foreground text-sm ml-1">
{count.toLocaleString()}
</p>
</div> </div>
); );
}; };

View File

@@ -1,14 +1,19 @@
import { CircleDashed } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
DatabaseViewFilterAttributes, DatabaseViewFilterAttributes,
FieldValue, FieldValue,
MultiSelectFieldAttributes, MultiSelectFieldAttributes,
SelectOptionAttributes,
} from '@colanode/core'; } from '@colanode/core';
import { BoardViewColumn } from '@colanode/ui/components/databases/boards/board-view-column'; import { BoardViewColumn } from '@colanode/ui/components/databases/boards/board-view-column';
import { SelectOptionBadge } from '@colanode/ui/components/databases/fields/select-option-badge'; import { SelectOptionBadge } from '@colanode/ui/components/databases/fields/select-option-badge';
import { BoardViewContext } from '@colanode/ui/contexts/board-view'; import { BoardViewContext } from '@colanode/ui/contexts/board-view';
import { useDatabase } from '@colanode/ui/contexts/database';
import { useDatabaseView } from '@colanode/ui/contexts/database-view';
import { useWorkspace } from '@colanode/ui/contexts/workspace'; import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useQuery } from '@colanode/ui/hooks/use-query';
import { getSelectOptionLightColorClass } from '@colanode/ui/lib/databases'; import { getSelectOptionLightColorClass } from '@colanode/ui/lib/databases';
interface BoardViewColumnsMultiSelectProps { interface BoardViewColumnsMultiSelectProps {
@@ -19,8 +24,19 @@ export const BoardViewColumnsMultiSelect = ({
field, field,
}: BoardViewColumnsMultiSelectProps) => { }: BoardViewColumnsMultiSelectProps) => {
const workspace = useWorkspace(); const workspace = useWorkspace();
const selectOptions = Object.values(field.options ?? {}); const database = useDatabase();
const view = useDatabaseView();
const selectOptionCountQuery = useQuery({
type: 'record.field.value.count',
databaseId: database.id,
filters: view.filters,
fieldId: field.id,
accountId: workspace.accountId,
workspaceId: workspace.id,
});
const selectOptions = Object.values(field.options ?? {});
const noValueFilter: DatabaseViewFilterAttributes = { const noValueFilter: DatabaseViewFilterAttributes = {
id: '1', id: '1',
type: 'field', type: 'field',
@@ -28,6 +44,9 @@ export const BoardViewColumnsMultiSelect = ({
operator: 'is_empty', operator: 'is_empty',
}; };
const selectOptionCount = selectOptionCountQuery.data?.values ?? [];
const noValueCount = selectOptionCountQuery.data?.nullCount ?? 0;
const noValueDraggingClass = getSelectOptionLightColorClass('gray'); const noValueDraggingClass = getSelectOptionLightColorClass('gray');
return ( return (
@@ -45,6 +64,10 @@ export const BoardViewColumnsMultiSelect = ({
option.color ?? 'gray' option.color ?? 'gray'
); );
const count =
selectOptionCount.find((count) => count.value === option.id)?.count ??
0;
return ( return (
<BoardViewContext.Provider <BoardViewContext.Provider
key={option.id} key={option.id}
@@ -60,7 +83,11 @@ export const BoardViewColumnsMultiSelect = ({
}, },
dragOverClass: draggingClass, dragOverClass: draggingClass,
header: ( header: (
<SelectOptionBadge name={option.name} color={option.color} /> <BoardViewColumnMultiSelectHeader
field={field}
option={option}
count={count}
/>
), ),
canDrag: (record) => record.canEdit, canDrag: (record) => record.canEdit,
onDragEnd: async (record, value) => { onDragEnd: async (record, value) => {
@@ -126,7 +153,11 @@ export const BoardViewColumnsMultiSelect = ({
return null; return null;
}, },
header: ( header: (
<p className="text-sm text-muted-foreground">No {field.name}</p> <BoardViewColumnMultiSelectHeader
field={field}
option={null}
count={noValueCount}
/>
), ),
dragOverClass: noValueDraggingClass, dragOverClass: noValueDraggingClass,
canDrag: () => true, canDrag: () => true,
@@ -165,3 +196,36 @@ export const BoardViewColumnsMultiSelect = ({
</> </>
); );
}; };
interface BoardViewColumnMultiSelectHeaderProps {
field: MultiSelectFieldAttributes;
option: SelectOptionAttributes | null;
count: number;
}
const BoardViewColumnMultiSelectHeader = ({
field,
option,
count,
}: BoardViewColumnMultiSelectHeaderProps) => {
if (!option) {
return (
<div className="flex flex-row gap-2 items-center">
<CircleDashed className="size-5" />
<p className="text-muted-foreground">No {field.name}</p>
<p className="text-muted-foreground text-sm ml-1">
{count.toLocaleString()}
</p>
</div>
);
}
return (
<div className="flex flex-row gap-2 items-center">
<SelectOptionBadge name={option.name} color={option.color} />
<p className="text-muted-foreground text-sm ml-1">
{count.toLocaleString()}
</p>
</div>
);
};

View File

@@ -1,13 +1,18 @@
import { CircleDashed } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
DatabaseViewFilterAttributes, DatabaseViewFilterAttributes,
SelectFieldAttributes, SelectFieldAttributes,
SelectOptionAttributes,
} from '@colanode/core'; } from '@colanode/core';
import { BoardViewColumn } from '@colanode/ui/components/databases/boards/board-view-column'; import { BoardViewColumn } from '@colanode/ui/components/databases/boards/board-view-column';
import { SelectOptionBadge } from '@colanode/ui/components/databases/fields/select-option-badge'; import { SelectOptionBadge } from '@colanode/ui/components/databases/fields/select-option-badge';
import { BoardViewContext } from '@colanode/ui/contexts/board-view'; import { BoardViewContext } from '@colanode/ui/contexts/board-view';
import { useDatabase } from '@colanode/ui/contexts/database';
import { useDatabaseView } from '@colanode/ui/contexts/database-view';
import { useWorkspace } from '@colanode/ui/contexts/workspace'; import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useQuery } from '@colanode/ui/hooks/use-query';
import { getSelectOptionLightColorClass } from '@colanode/ui/lib/databases'; import { getSelectOptionLightColorClass } from '@colanode/ui/lib/databases';
interface BoardViewColumnsSelectProps { interface BoardViewColumnsSelectProps {
@@ -18,8 +23,19 @@ export const BoardViewColumnsSelect = ({
field, field,
}: BoardViewColumnsSelectProps) => { }: BoardViewColumnsSelectProps) => {
const workspace = useWorkspace(); const workspace = useWorkspace();
const selectOptions = Object.values(field.options ?? {}); const database = useDatabase();
const view = useDatabaseView();
const selectOptionCountQuery = useQuery({
type: 'record.field.value.count',
databaseId: database.id,
filters: view.filters,
fieldId: field.id,
accountId: workspace.accountId,
workspaceId: workspace.id,
});
const selectOptions = Object.values(field.options ?? {});
const noValueFilter: DatabaseViewFilterAttributes = { const noValueFilter: DatabaseViewFilterAttributes = {
id: '1', id: '1',
type: 'field', type: 'field',
@@ -27,6 +43,9 @@ export const BoardViewColumnsSelect = ({
operator: 'is_empty', operator: 'is_empty',
}; };
const selectOptionCount = selectOptionCountQuery.data?.values ?? [];
const noValueCount = selectOptionCountQuery.data?.nullCount ?? 0;
const noValueDraggingClass = getSelectOptionLightColorClass('gray'); const noValueDraggingClass = getSelectOptionLightColorClass('gray');
return ( return (
@@ -44,6 +63,10 @@ export const BoardViewColumnsSelect = ({
option.color ?? 'gray' option.color ?? 'gray'
); );
const count =
selectOptionCount.find((count) => count.value === option.id)?.count ??
0;
return ( return (
<BoardViewContext.Provider <BoardViewContext.Provider
key={option.id} key={option.id}
@@ -59,7 +82,11 @@ export const BoardViewColumnsSelect = ({
}, },
dragOverClass: draggingClass, dragOverClass: draggingClass,
header: ( header: (
<SelectOptionBadge name={option.name} color={option.color} /> <BoardViewColumnSelectHeader
field={field}
option={option}
count={count}
/>
), ),
canDrag: (record) => record.canEdit, canDrag: (record) => record.canEdit,
onDragEnd: async (record, value) => { onDragEnd: async (record, value) => {
@@ -105,7 +132,11 @@ export const BoardViewColumnsSelect = ({
return null; return null;
}, },
header: ( header: (
<p className="text-sm text-muted-foreground">No {field.name}</p> <BoardViewColumnSelectHeader
field={field}
option={null}
count={noValueCount}
/>
), ),
dragOverClass: noValueDraggingClass, dragOverClass: noValueDraggingClass,
canDrag: () => true, canDrag: () => true,
@@ -144,3 +175,36 @@ export const BoardViewColumnsSelect = ({
</> </>
); );
}; };
interface BoardViewColumnSelectHeaderProps {
field: SelectFieldAttributes;
option: SelectOptionAttributes | null;
count: number;
}
const BoardViewColumnSelectHeader = ({
field,
option,
count,
}: BoardViewColumnSelectHeaderProps) => {
if (!option) {
return (
<div className="flex flex-row gap-2 items-center">
<CircleDashed className="size-5" />
<p className="text-muted-foreground">No {field.name}</p>
<p className="text-muted-foreground text-sm ml-1">
{count.toLocaleString()}
</p>
</div>
);
}
return (
<div className="flex flex-row gap-2 items-center">
<SelectOptionBadge name={option?.name} color={option?.color} />
<p className="text-muted-foreground text-sm ml-1">
{count.toLocaleString()}
</p>
</div>
);
};