diff --git a/apps/client/src/features/base/components/views/kanban/base-kanban.tsx b/apps/client/src/features/base/components/views/kanban/base-kanban.tsx
index cd76ed92c..8498b2665 100644
--- a/apps/client/src/features/base/components/views/kanban/base-kanban.tsx
+++ b/apps/client/src/features/base/components/views/kanban/base-kanban.tsx
@@ -5,7 +5,9 @@ import {
IBaseView,
} from "@/features/base/types/base.types";
import { useKanbanGroups } from "@/features/base/hooks/use-kanban-groups";
+import { useUpdateViewMutation } from "@/features/base/queries/base-view-query";
import { KanbanColumn } from "./kanban-column";
+import { KanbanEmptyState } from "./kanban-empty-state";
import classes from "@/features/base/styles/kanban.module.css";
type BaseKanbanProps = {
@@ -34,6 +36,10 @@ export function BaseKanban({
[base.properties],
);
const isGroupable = property?.type === "select" || property?.type === "status";
+ const updateViewMutation = useUpdateViewMutation();
+
+ // Rules of Hooks: call useKanbanGroups unconditionally with `undefined`
+ // when not groupable; switch the render path on isGroupable below.
const { columns } = useKanbanGroups(
rows,
isGroupable ? property : undefined,
@@ -41,6 +47,19 @@ export function BaseKanban({
effectiveView?.config?.choiceOrder,
);
+ const handlePickProperty = (propertyId: string) => {
+ if (!effectiveView) return;
+ updateViewMutation.mutate({
+ viewId: effectiveView.id,
+ pageId: base.id,
+ config: { ...effectiveView.config, groupByPropertyId: propertyId },
+ });
+ };
+
+ if (!isGroupable) {
+ return ;
+ }
+
return (
{columns.map((column) => (
diff --git a/apps/client/src/features/base/components/views/kanban/kanban-empty-state.tsx b/apps/client/src/features/base/components/views/kanban/kanban-empty-state.tsx
new file mode 100644
index 000000000..0dba2c2d7
--- /dev/null
+++ b/apps/client/src/features/base/components/views/kanban/kanban-empty-state.tsx
@@ -0,0 +1,52 @@
+import { Stack, Text } from "@mantine/core";
+import { IconColumns3 } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { IBase } from "@/features/base/types/base.types";
+import { KanbanGroupByPicker } from "./kanban-group-by-picker";
+import { CreatePropertyPopover } from "@/features/base/components/property/create-property-popover";
+
+type KanbanEmptyStateProps = {
+ base: IBase;
+ onPick: (propertyId: string) => void;
+};
+
+export function KanbanEmptyState({ base, onPick }: KanbanEmptyStateProps) {
+ const { t } = useTranslation();
+ const hasGroupable = base.properties.some(
+ (p) => p.type === "select" || p.type === "status",
+ );
+
+ return (
+
+
+
+ {t("Choose a property to group by")}
+
+ {hasGroupable ? (
+
+ ) : (
+
+
+ {t("Create a select or status property to use the kanban view.")}
+
+ {
+ // The base query invalidates on property create — the empty
+ // state will re-render with the picker variant. The user
+ // then picks the new property explicitly. (Auto-picking the
+ // new property requires receiving its id from the create
+ // mutation, which the current popover doesn't expose. Keep
+ // the explicit step for now.)
+ }}
+ />
+
+ )}
+
+ );
+}
diff --git a/apps/client/src/features/base/components/views/kanban/kanban-group-by-picker.tsx b/apps/client/src/features/base/components/views/kanban/kanban-group-by-picker.tsx
new file mode 100644
index 000000000..9d47198f8
--- /dev/null
+++ b/apps/client/src/features/base/components/views/kanban/kanban-group-by-picker.tsx
@@ -0,0 +1,39 @@
+import { useMemo } from "react";
+import { Select } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import { IBaseProperty } from "@/features/base/types/base.types";
+
+type KanbanGroupByPickerProps = {
+ properties: IBaseProperty[];
+ value: string | null;
+ onChange: (propertyId: string) => void;
+ // Allows the toolbar variant to render compact / narrow.
+ size?: "xs" | "sm" | "md";
+};
+
+export function KanbanGroupByPicker({
+ properties,
+ value,
+ onChange,
+ size = "sm",
+}: KanbanGroupByPickerProps) {
+ const { t } = useTranslation();
+ const data = useMemo(
+ () =>
+ properties
+ .filter((p) => p.type === "select" || p.type === "status")
+ .map((p) => ({ value: p.id, label: p.name || t("Untitled") })),
+ [properties, t],
+ );
+ return (
+