mirror of
https://github.com/docmost/docmost.git
synced 2026-06-14 03:29:56 +00:00
feat: kanban view
This commit is contained in:
@@ -28,6 +28,8 @@ COPY --from=builder /app/apps/server/package.json /app/apps/server/package.json
|
|||||||
# Copy packages
|
# Copy packages
|
||||||
COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
|
COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
|
||||||
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
||||||
|
COPY --from=builder /app/packages/base-formula/dist /app/packages/base-formula/dist
|
||||||
|
COPY --from=builder /app/packages/base-formula/package.json /app/packages/base-formula/package.json
|
||||||
|
|
||||||
# Copy root package files
|
# Copy root package files
|
||||||
COPY --from=builder /app/package.json /app/package.json
|
COPY --from=builder /app/package.json /app/package.json
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
IconEye,
|
IconEye,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconArrowsDiagonal,
|
IconArrowsDiagonal,
|
||||||
|
IconLayoutColumns,
|
||||||
|
IconAdjustments,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import {
|
import {
|
||||||
@@ -23,19 +25,20 @@ import { ViewTabs } from "@/ee/base/components/views/view-tabs";
|
|||||||
import { ViewSortConfigPopover } from "@/ee/base/components/views/view-sort-config";
|
import { ViewSortConfigPopover } from "@/ee/base/components/views/view-sort-config";
|
||||||
import { ViewFilterConfigPopover } from "@/ee/base/components/views/view-filter-config";
|
import { ViewFilterConfigPopover } from "@/ee/base/components/views/view-filter-config";
|
||||||
import { ViewPropertyVisibility } from "@/ee/base/components/views/view-property-visibility";
|
import { ViewPropertyVisibility } from "@/ee/base/components/views/view-property-visibility";
|
||||||
|
import { KanbanGroupByPicker } from "@/ee/base/components/kanban/kanban-group-by-picker";
|
||||||
|
import { KanbanCardProperties } from "@/ee/base/components/kanban/kanban-card-properties";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import classes from "@/ee/base/styles/grid.module.css";
|
import classes from "@/ee/base/styles/grid.module.css";
|
||||||
import toolbarClasses from "@/ee/base/styles/base-toolbar.module.css";
|
import toolbarClasses from "@/ee/base/styles/base-toolbar.module.css";
|
||||||
|
|
||||||
type BaseToolbarProps = {
|
type BaseToolbarProps = {
|
||||||
base: IBase;
|
base: IBase;
|
||||||
// Effective view (baseline merged with local draft). Badge counts and popover
|
|
||||||
// seed data read from this; the real baseline only enters via the draft callbacks.
|
|
||||||
activeView: IBaseView | undefined;
|
activeView: IBaseView | undefined;
|
||||||
views: IBaseView[];
|
views: IBaseView[];
|
||||||
table: Table<IBaseRow>;
|
table?: Table<IBaseRow>;
|
||||||
onViewChange: (viewId: string) => void;
|
onViewChange: (viewId: string) => void;
|
||||||
onAddView?: () => void;
|
onAddView?: () => void;
|
||||||
|
canAddView?: boolean;
|
||||||
onPersistViewConfig: () => void;
|
onPersistViewConfig: () => void;
|
||||||
onDraftSortsChange: (sorts: ViewSortConfig[] | undefined) => void;
|
onDraftSortsChange: (sorts: ViewSortConfig[] | undefined) => void;
|
||||||
onDraftFiltersChange: (filter: FilterGroup | undefined) => void;
|
onDraftFiltersChange: (filter: FilterGroup | undefined) => void;
|
||||||
@@ -50,6 +53,7 @@ export function BaseToolbar({
|
|||||||
table,
|
table,
|
||||||
onViewChange,
|
onViewChange,
|
||||||
onAddView,
|
onAddView,
|
||||||
|
canAddView,
|
||||||
onPersistViewConfig,
|
onPersistViewConfig,
|
||||||
onDraftSortsChange,
|
onDraftSortsChange,
|
||||||
onDraftFiltersChange,
|
onDraftFiltersChange,
|
||||||
@@ -61,8 +65,11 @@ export function BaseToolbar({
|
|||||||
const [sortOpened, setSortOpened] = useState(false);
|
const [sortOpened, setSortOpened] = useState(false);
|
||||||
const [filterOpened, setFilterOpened] = useState(false);
|
const [filterOpened, setFilterOpened] = useState(false);
|
||||||
const [propertiesOpened, setPropertiesOpened] = useState(false);
|
const [propertiesOpened, setPropertiesOpened] = useState(false);
|
||||||
|
const [cardPropertiesOpened, setCardPropertiesOpened] = useState(false);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
|
const isKanban = activeView?.type === "kanban";
|
||||||
|
|
||||||
const handleExport = useCallback(async () => {
|
const handleExport = useCallback(async () => {
|
||||||
if (exporting) return;
|
if (exporting) return;
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
@@ -85,8 +92,6 @@ export function BaseToolbar({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sorts = activeView?.config?.sorts ?? [];
|
const sorts = activeView?.config?.sorts ?? [];
|
||||||
// The stored filter is a tree; the popover edits an AND-only flat list.
|
|
||||||
// Unwrap the top-level group's children when reading, rewrap on save.
|
|
||||||
const conditions = useMemo<FilterCondition[]>(() => {
|
const conditions = useMemo<FilterCondition[]>(() => {
|
||||||
const filter = activeView?.config?.filter;
|
const filter = activeView?.config?.filter;
|
||||||
if (!filter || filter.op !== "and") return [];
|
if (!filter || filter.op !== "and") return [];
|
||||||
@@ -96,13 +101,13 @@ export function BaseToolbar({
|
|||||||
}, [activeView?.config?.filter]);
|
}, [activeView?.config?.filter]);
|
||||||
|
|
||||||
const hiddenPropertyCount = useMemo(() => {
|
const hiddenPropertyCount = useMemo(() => {
|
||||||
|
if (!table) return 0;
|
||||||
const cols = table.getAllLeafColumns().filter((col) => col.id !== "__row_number");
|
const cols = table.getAllLeafColumns().filter((col) => col.id !== "__row_number");
|
||||||
return cols.filter((col) => col.getCanHide() && !col.getIsVisible()).length;
|
return cols.filter((col) => col.getCanHide() && !col.getIsVisible()).length;
|
||||||
}, [table, table.getState().columnVisibility]);
|
}, [table, table?.getState().columnVisibility]);
|
||||||
|
|
||||||
const handleSortsChange = useCallback(
|
const handleSortsChange = useCallback(
|
||||||
(newSorts: ViewSortConfig[]) => {
|
(newSorts: ViewSortConfig[]) => {
|
||||||
// Normalize empty to undefined so the draft hook drops the sorts axis.
|
|
||||||
onDraftSortsChange(newSorts.length > 0 ? newSorts : undefined);
|
onDraftSortsChange(newSorts.length > 0 ? newSorts : undefined);
|
||||||
},
|
},
|
||||||
[onDraftSortsChange],
|
[onDraftSortsChange],
|
||||||
@@ -110,7 +115,6 @@ export function BaseToolbar({
|
|||||||
|
|
||||||
const handleFiltersChange = useCallback(
|
const handleFiltersChange = useCallback(
|
||||||
(newConditions: FilterCondition[]) => {
|
(newConditions: FilterCondition[]) => {
|
||||||
// Wrap the AND-flat list into the engine's FilterGroup shape; undefined drops the axis.
|
|
||||||
const filter: FilterGroup | undefined =
|
const filter: FilterGroup | undefined =
|
||||||
newConditions.length > 0
|
newConditions.length > 0
|
||||||
? { op: "and", children: newConditions }
|
? { op: "and", children: newConditions }
|
||||||
@@ -128,6 +132,8 @@ export function BaseToolbar({
|
|||||||
pageId={base.id}
|
pageId={base.id}
|
||||||
onViewChange={onViewChange}
|
onViewChange={onViewChange}
|
||||||
onAddView={onAddView}
|
onAddView={onAddView}
|
||||||
|
base={base}
|
||||||
|
canAddView={canAddView}
|
||||||
getViewShareUrl={getViewShareUrl}
|
getViewShareUrl={getViewShareUrl}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -175,63 +181,104 @@ export function BaseToolbar({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ViewFilterConfigPopover>
|
</ViewFilterConfigPopover>
|
||||||
|
|
||||||
<ViewSortConfigPopover
|
{isKanban && activeView && (
|
||||||
opened={sortOpened}
|
<>
|
||||||
onClose={() => setSortOpened(false)}
|
<KanbanGroupByPicker base={base} view={activeView} pageId={base.id}>
|
||||||
sorts={sorts}
|
<Tooltip label={t("Group by")}>
|
||||||
properties={base.properties}
|
<ActionIcon
|
||||||
onChange={handleSortsChange}
|
variant="subtle"
|
||||||
>
|
size="sm"
|
||||||
<Tooltip label={t("Sort")}>
|
color="gray"
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
size="sm"
|
|
||||||
color={sorts.length > 0 ? "blue" : "gray"}
|
|
||||||
onClick={() => openToolbar("sort")}
|
|
||||||
>
|
|
||||||
<IconSortAscending size={16} />
|
|
||||||
{sorts.length > 0 && (
|
|
||||||
<Badge
|
|
||||||
size="xs"
|
|
||||||
circle
|
|
||||||
color="blue"
|
|
||||||
className={toolbarClasses.badgeDot}
|
|
||||||
>
|
>
|
||||||
{sorts.length}
|
<IconLayoutColumns size={16} />
|
||||||
</Badge>
|
</ActionIcon>
|
||||||
)}
|
</Tooltip>
|
||||||
</ActionIcon>
|
</KanbanGroupByPicker>
|
||||||
</Tooltip>
|
|
||||||
</ViewSortConfigPopover>
|
|
||||||
|
|
||||||
<ViewPropertyVisibility
|
<KanbanCardProperties
|
||||||
opened={propertiesOpened}
|
opened={cardPropertiesOpened}
|
||||||
onClose={() => setPropertiesOpened(false)}
|
onClose={() => setCardPropertiesOpened(false)}
|
||||||
table={table}
|
base={base}
|
||||||
properties={base.properties}
|
view={activeView}
|
||||||
onPersist={onPersistViewConfig}
|
pageId={base.id}
|
||||||
>
|
|
||||||
<Tooltip label={t("Hide properties")}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
size="sm"
|
|
||||||
color={hiddenPropertyCount > 0 ? "blue" : "gray"}
|
|
||||||
onClick={() => openToolbar("properties")}
|
|
||||||
>
|
>
|
||||||
<IconEye size={16} />
|
<Tooltip label={t("Card properties")}>
|
||||||
{hiddenPropertyCount > 0 && (
|
<ActionIcon
|
||||||
<Badge
|
variant="subtle"
|
||||||
size="xs"
|
size="sm"
|
||||||
circle
|
color="gray"
|
||||||
color="blue"
|
onClick={() => setCardPropertiesOpened((v) => !v)}
|
||||||
className={toolbarClasses.badgeDot}
|
|
||||||
>
|
>
|
||||||
{hiddenPropertyCount}
|
<IconAdjustments size={16} />
|
||||||
</Badge>
|
</ActionIcon>
|
||||||
)}
|
</Tooltip>
|
||||||
</ActionIcon>
|
</KanbanCardProperties>
|
||||||
</Tooltip>
|
</>
|
||||||
</ViewPropertyVisibility>
|
)}
|
||||||
|
|
||||||
|
{!isKanban && (
|
||||||
|
<>
|
||||||
|
<ViewSortConfigPopover
|
||||||
|
opened={sortOpened}
|
||||||
|
onClose={() => setSortOpened(false)}
|
||||||
|
sorts={sorts}
|
||||||
|
properties={base.properties}
|
||||||
|
onChange={handleSortsChange}
|
||||||
|
>
|
||||||
|
<Tooltip label={t("Sort")}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
color={sorts.length > 0 ? "blue" : "gray"}
|
||||||
|
onClick={() => openToolbar("sort")}
|
||||||
|
>
|
||||||
|
<IconSortAscending size={16} />
|
||||||
|
{sorts.length > 0 && (
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
circle
|
||||||
|
color="blue"
|
||||||
|
className={toolbarClasses.badgeDot}
|
||||||
|
>
|
||||||
|
{sorts.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</ViewSortConfigPopover>
|
||||||
|
|
||||||
|
{table && (
|
||||||
|
<ViewPropertyVisibility
|
||||||
|
opened={propertiesOpened}
|
||||||
|
onClose={() => setPropertiesOpened(false)}
|
||||||
|
table={table}
|
||||||
|
properties={base.properties}
|
||||||
|
onPersist={onPersistViewConfig}
|
||||||
|
>
|
||||||
|
<Tooltip label={t("Hide properties")}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
color={hiddenPropertyCount > 0 ? "blue" : "gray"}
|
||||||
|
onClick={() => openToolbar("properties")}
|
||||||
|
>
|
||||||
|
<IconEye size={16} />
|
||||||
|
{hiddenPropertyCount > 0 && (
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
circle
|
||||||
|
color="blue"
|
||||||
|
className={toolbarClasses.badgeDot}
|
||||||
|
>
|
||||||
|
{hiddenPropertyCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</ViewPropertyVisibility>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{onExpand && (
|
{onExpand && (
|
||||||
<Tooltip label={t("Open as page")}>
|
<Tooltip label={t("Open as page")}>
|
||||||
|
|||||||
@@ -22,10 +22,7 @@ import {
|
|||||||
useUpdateRowMutation,
|
useUpdateRowMutation,
|
||||||
useReorderRowMutation,
|
useReorderRowMutation,
|
||||||
} from "@/ee/base/queries/base-row-query";
|
} from "@/ee/base/queries/base-row-query";
|
||||||
import {
|
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
|
||||||
useCreateViewMutation,
|
|
||||||
useUpdateViewMutation,
|
|
||||||
} from "@/ee/base/queries/base-view-query";
|
|
||||||
import {
|
import {
|
||||||
activeViewIdAtomFamily,
|
activeViewIdAtomFamily,
|
||||||
editingCellAtomFamily,
|
editingCellAtomFamily,
|
||||||
@@ -51,6 +48,7 @@ import { getAppUrl } from "@/lib/config.ts";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import classes from "@/ee/base/styles/grid.module.css";
|
import classes from "@/ee/base/styles/grid.module.css";
|
||||||
import viewClasses from "@/ee/base/styles/base-view.module.css";
|
import viewClasses from "@/ee/base/styles/base-view.module.css";
|
||||||
|
import kanbanClasses from "@/ee/base/styles/kanban.module.css";
|
||||||
|
|
||||||
type BaseViewProps = {
|
type BaseViewProps = {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -146,13 +144,15 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
|
|||||||
|
|
||||||
// Gate on base to avoid a "bland" list request before the active view's
|
// Gate on base to avoid a "bland" list request before the active view's
|
||||||
// config resolves, which would double network traffic for sorted/filtered views.
|
// config resolves, which would double network traffic for sorted/filtered views.
|
||||||
|
const isKanban = activeView?.type === "kanban";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: rowsData,
|
data: rowsData,
|
||||||
isLoading: rowsLoading,
|
isLoading: rowsLoading,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
} = useBaseRowsQuery(base ? pageId : undefined, activeFilter, activeSorts);
|
} = useBaseRowsQuery(base && !isKanban ? pageId : undefined, activeFilter, activeSorts);
|
||||||
|
|
||||||
// Warm the count cache alongside the rows query. Gate on currentUser so
|
// Warm the count cache alongside the rows query. Gate on currentUser so
|
||||||
// useViewDraft has hydrated from localStorage before the count fires.
|
// useViewDraft has hydrated from localStorage before the count fires.
|
||||||
@@ -162,7 +162,6 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
|
|||||||
const updateRowMutation = useUpdateRowMutation();
|
const updateRowMutation = useUpdateRowMutation();
|
||||||
const createRowMutation = useCreateRowMutation();
|
const createRowMutation = useCreateRowMutation();
|
||||||
const reorderRowMutation = useReorderRowMutation();
|
const reorderRowMutation = useReorderRowMutation();
|
||||||
const createViewMutation = useCreateViewMutation();
|
|
||||||
const updateViewMutation = useUpdateViewMutation();
|
const updateViewMutation = useUpdateViewMutation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -263,15 +262,6 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
|
|||||||
[setActiveViewId],
|
[setActiveViewId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddView = useCallback(() => {
|
|
||||||
if (!editable) return;
|
|
||||||
createViewMutation.mutate({
|
|
||||||
pageId,
|
|
||||||
name: t("New view"),
|
|
||||||
type: "table",
|
|
||||||
});
|
|
||||||
}, [editable, pageId, createViewMutation, t]);
|
|
||||||
|
|
||||||
const handleColumnReorder = useCallback(
|
const handleColumnReorder = useCallback(
|
||||||
(columnId: string, finishIndex: number) => {
|
(columnId: string, finishIndex: number) => {
|
||||||
const order = table.getState().columnOrder;
|
const order = table.getState().columnOrder;
|
||||||
@@ -374,7 +364,7 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
|
|||||||
[editable, pageId, reorderRow],
|
[editable, pageId, reorderRow],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (baseLoading || rowsLoading) {
|
if (baseLoading || (!isKanban && rowsLoading)) {
|
||||||
return <BaseTableSkeleton />;
|
return <BaseTableSkeleton />;
|
||||||
}
|
}
|
||||||
if (baseError) {
|
if (baseError) {
|
||||||
@@ -407,7 +397,7 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
|
|||||||
views={views}
|
views={views}
|
||||||
table={table}
|
table={table}
|
||||||
onViewChange={handleViewChange}
|
onViewChange={handleViewChange}
|
||||||
onAddView={editable ? handleAddView : undefined}
|
canAddView={editable}
|
||||||
onPersistViewConfig={guardedPersistViewConfig}
|
onPersistViewConfig={guardedPersistViewConfig}
|
||||||
onDraftSortsChange={handleDraftSortsChange}
|
onDraftSortsChange={handleDraftSortsChange}
|
||||||
onDraftFiltersChange={handleDraftFiltersChange}
|
onDraftFiltersChange={handleDraftFiltersChange}
|
||||||
@@ -416,38 +406,71 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const kanbanBand = (
|
||||||
|
<div className={kanbanClasses.bandWrap}>
|
||||||
|
{embedded ? null : titleSlot}
|
||||||
|
{banner}
|
||||||
|
{toolbar}
|
||||||
|
{embedded ? <BaseEmbedTitle pageId={pageId} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const viewRenderer = (folded: React.ReactNode) => (
|
||||||
|
<ViewRenderer
|
||||||
|
base={base}
|
||||||
|
rows={rows}
|
||||||
|
effectiveView={effectiveView}
|
||||||
|
table={table}
|
||||||
|
pageId={pageId}
|
||||||
|
embedded={embedded}
|
||||||
|
editable={editable}
|
||||||
|
isFiltered={isFiltered}
|
||||||
|
hasNextPage={!!hasNextPage}
|
||||||
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
|
onFetchNextPage={fetchNextPage}
|
||||||
|
onCellUpdate={handleCellUpdate}
|
||||||
|
onAddRow={handleAddRow}
|
||||||
|
onColumnReorder={editable ? handleColumnReorder : undefined}
|
||||||
|
onResizeEnd={handleResizeEnd}
|
||||||
|
onRowReorder={editable ? handleRowReorder : undefined}
|
||||||
|
persistViewConfig={guardedPersistViewConfig}
|
||||||
|
scrollportRef={scrollportRef}
|
||||||
|
kanbanFilter={activeFilter}
|
||||||
|
aboveBand={folded}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
if (embedded) {
|
if (embedded) {
|
||||||
|
if (isKanban) {
|
||||||
|
return (
|
||||||
|
<BaseEditableProvider editable={editable}>
|
||||||
|
<RowExpandProvider value={handleExpandRow}>
|
||||||
|
{kanbanBand}
|
||||||
|
{viewRenderer(null)}
|
||||||
|
</RowExpandProvider>
|
||||||
|
<RowDetailModal
|
||||||
|
base={base}
|
||||||
|
rows={rows}
|
||||||
|
openRowId={openRowId}
|
||||||
|
onClose={closeRow}
|
||||||
|
onNavigate={handleRowNavigate}
|
||||||
|
/>
|
||||||
|
</BaseEditableProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Banner and toolbar go into aboveBand so they scroll with the host document;
|
// Banner and toolbar go into aboveBand so they scroll with the host document;
|
||||||
// only the column-header row stays pinned (via --sticky-band-top).
|
// only the column-header row stays pinned (via --sticky-band-top).
|
||||||
return (
|
return (
|
||||||
<BaseEditableProvider editable={editable}>
|
<BaseEditableProvider editable={editable}>
|
||||||
<RowExpandProvider value={handleExpandRow}>
|
<RowExpandProvider value={handleExpandRow}>
|
||||||
<ViewRenderer
|
{viewRenderer(
|
||||||
base={base}
|
<>
|
||||||
rows={rows}
|
{banner}
|
||||||
effectiveView={effectiveView}
|
{toolbar}
|
||||||
table={table}
|
<BaseEmbedTitle pageId={pageId} />
|
||||||
pageId={pageId}
|
</>,
|
||||||
embedded={embedded}
|
)}
|
||||||
isFiltered={isFiltered}
|
|
||||||
hasNextPage={!!hasNextPage}
|
|
||||||
isFetchingNextPage={isFetchingNextPage}
|
|
||||||
onFetchNextPage={fetchNextPage}
|
|
||||||
onCellUpdate={handleCellUpdate}
|
|
||||||
onAddRow={handleAddRow}
|
|
||||||
onColumnReorder={editable ? handleColumnReorder : undefined}
|
|
||||||
onResizeEnd={handleResizeEnd}
|
|
||||||
onRowReorder={editable ? handleRowReorder : undefined}
|
|
||||||
persistViewConfig={guardedPersistViewConfig}
|
|
||||||
scrollportRef={scrollportRef}
|
|
||||||
aboveBand={
|
|
||||||
<>
|
|
||||||
{banner}
|
|
||||||
{toolbar}
|
|
||||||
<BaseEmbedTitle pageId={pageId} />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</RowExpandProvider>
|
</RowExpandProvider>
|
||||||
<RowDetailModal
|
<RowDetailModal
|
||||||
base={base}
|
base={base}
|
||||||
@@ -460,6 +483,26 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isKanban) {
|
||||||
|
return (
|
||||||
|
<BaseEditableProvider editable={editable}>
|
||||||
|
<div className={kanbanClasses.standalone}>
|
||||||
|
<RowExpandProvider value={handleExpandRow}>
|
||||||
|
{kanbanBand}
|
||||||
|
{viewRenderer(null)}
|
||||||
|
</RowExpandProvider>
|
||||||
|
</div>
|
||||||
|
<RowDetailModal
|
||||||
|
base={base}
|
||||||
|
rows={rows}
|
||||||
|
openRowId={openRowId}
|
||||||
|
onClose={closeRow}
|
||||||
|
onNavigate={handleRowNavigate}
|
||||||
|
/>
|
||||||
|
</BaseEditableProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Standalone: title, banner, and toolbar go in aboveBand inside the scroll
|
// Standalone: title, banner, and toolbar go in aboveBand inside the scroll
|
||||||
// container so they scroll away; only the column-header row stays pinned.
|
// container so they scroll away; only the column-header row stays pinned.
|
||||||
return (
|
return (
|
||||||
@@ -467,32 +510,13 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
|
|||||||
<div className={viewClasses.fullHeight}>
|
<div className={viewClasses.fullHeight}>
|
||||||
<div className={classes.tableScrollport} ref={scrollportRef}>
|
<div className={classes.tableScrollport} ref={scrollportRef}>
|
||||||
<RowExpandProvider value={handleExpandRow}>
|
<RowExpandProvider value={handleExpandRow}>
|
||||||
<ViewRenderer
|
{viewRenderer(
|
||||||
base={base}
|
<>
|
||||||
rows={rows}
|
{titleSlot}
|
||||||
effectiveView={effectiveView}
|
{banner}
|
||||||
table={table}
|
{toolbar}
|
||||||
pageId={pageId}
|
</>,
|
||||||
embedded={embedded}
|
)}
|
||||||
isFiltered={isFiltered}
|
|
||||||
hasNextPage={!!hasNextPage}
|
|
||||||
isFetchingNextPage={isFetchingNextPage}
|
|
||||||
onFetchNextPage={fetchNextPage}
|
|
||||||
onCellUpdate={handleCellUpdate}
|
|
||||||
onAddRow={handleAddRow}
|
|
||||||
onColumnReorder={editable ? handleColumnReorder : undefined}
|
|
||||||
onResizeEnd={handleResizeEnd}
|
|
||||||
onRowReorder={editable ? handleRowReorder : undefined}
|
|
||||||
persistViewConfig={guardedPersistViewConfig}
|
|
||||||
scrollportRef={scrollportRef}
|
|
||||||
aboveBand={
|
|
||||||
<>
|
|
||||||
{titleSlot}
|
|
||||||
{banner}
|
|
||||||
{toolbar}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</RowExpandProvider>
|
</RowExpandProvider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IBaseProperty } from "@/ee/base/types/base.types";
|
import { IBaseProperty } from "@/ee/base/types/base.types";
|
||||||
|
import { formatTimestamp } from "@/ee/base/formatters/cell-formatters";
|
||||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||||
|
|
||||||
type CellCreatedAtProps = {
|
type CellCreatedAtProps = {
|
||||||
@@ -10,21 +11,8 @@ type CellCreatedAtProps = {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatTimestamp(val: unknown): string {
|
|
||||||
if (typeof val !== "string" || !val) return "";
|
|
||||||
const date = new Date(val);
|
|
||||||
if (isNaN(date.getTime())) return "";
|
|
||||||
return date.toLocaleDateString(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CellCreatedAt({ value }: CellCreatedAtProps) {
|
export function CellCreatedAt({ value }: CellCreatedAtProps) {
|
||||||
const formatted = formatTimestamp(value);
|
const formatted = formatTimestamp(typeof value === "string" ? value : null);
|
||||||
|
|
||||||
if (!formatted) {
|
if (!formatted) {
|
||||||
return <span className={cellClasses.emptyValue} />;
|
return <span className={cellClasses.emptyValue} />;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IBaseProperty } from "@/ee/base/types/base.types";
|
import { IBaseProperty } from "@/ee/base/types/base.types";
|
||||||
|
import { formatTimestamp } from "@/ee/base/formatters/cell-formatters";
|
||||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||||
|
|
||||||
type CellLastEditedAtProps = {
|
type CellLastEditedAtProps = {
|
||||||
@@ -10,21 +11,8 @@ type CellLastEditedAtProps = {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatTimestamp(val: unknown): string {
|
|
||||||
if (typeof val !== "string" || !val) return "";
|
|
||||||
const date = new Date(val);
|
|
||||||
if (isNaN(date.getTime())) return "";
|
|
||||||
return date.toLocaleDateString(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CellLastEditedAt({ value }: CellLastEditedAtProps) {
|
export function CellLastEditedAt({ value }: CellLastEditedAtProps) {
|
||||||
const formatted = formatTimestamp(value);
|
const formatted = formatTimestamp(typeof value === "string" ? value : null);
|
||||||
|
|
||||||
if (!formatted) {
|
if (!formatted) {
|
||||||
return <span className={cellClasses.emptyValue} />;
|
return <span className={cellClasses.emptyValue} />;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { Popover, Textarea, Group, CloseButton, Tooltip } from "@mantine/core";
|
import { Popover, Textarea, Group, CloseButton, Tooltip } from "@mantine/core";
|
||||||
import { useDebouncedCallback } from "@mantine/hooks";
|
import { useDebouncedCallback } from "@mantine/hooks";
|
||||||
import { IBaseProperty } from "@/ee/base/types/base.types";
|
import { IBaseProperty } from "@/ee/base/types/base.types";
|
||||||
|
import { formatLongTextPreview } from "@/ee/base/formatters/cell-formatters";
|
||||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||||
|
|
||||||
type CellLongTextProps = {
|
type CellLongTextProps = {
|
||||||
@@ -68,7 +69,7 @@ export function CellLongText({
|
|||||||
onCancel();
|
onCancel();
|
||||||
};
|
};
|
||||||
|
|
||||||
const preview = toText(value).replace(/\s+/g, " ").trim();
|
const preview = formatLongTextPreview(toText(value));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
|
|||||||
@@ -5,14 +5,13 @@ import clsx from "clsx";
|
|||||||
import {
|
import {
|
||||||
IBaseProperty,
|
IBaseProperty,
|
||||||
PersonTypeOptions,
|
PersonTypeOptions,
|
||||||
UserRef,
|
|
||||||
} from "@/ee/base/types/base.types";
|
} from "@/ee/base/types/base.types";
|
||||||
import {
|
import {
|
||||||
useReferenceStore,
|
useReferenceStore,
|
||||||
useHydrateUsers,
|
useHydrateUsers,
|
||||||
} from "@/ee/base/reference/reference-store";
|
} from "@/ee/base/reference/reference-store";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||||
import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow";
|
import { PersonReadList } from "@/ee/base/components/cells/person-read-list";
|
||||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||||
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
|
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
|
||||||
import { usePersonSearch } from "@/ee/base/hooks/use-person-search";
|
import { usePersonSearch } from "@/ee/base/hooks/use-person-search";
|
||||||
@@ -245,38 +244,3 @@ export function CellPerson({
|
|||||||
return <PersonReadList personIds={personIds} users={store.users} />;
|
return <PersonReadList personIds={personIds} users={store.users} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PersonReadList({
|
|
||||||
personIds,
|
|
||||||
users,
|
|
||||||
}: {
|
|
||||||
personIds: string[];
|
|
||||||
users: Record<string, UserRef>;
|
|
||||||
}) {
|
|
||||||
const entries = personIds.map((id) => ({
|
|
||||||
id,
|
|
||||||
name: users[id]?.name ?? id.substring(0, 8),
|
|
||||||
avatarUrl: users[id]?.avatarUrl ?? "",
|
|
||||||
}));
|
|
||||||
const chips = entries.map((entry) => (
|
|
||||||
<span
|
|
||||||
key={entry.id}
|
|
||||||
className={clsx(cellClasses.badge, cellClasses.personChip)}
|
|
||||||
>
|
|
||||||
<CustomAvatar
|
|
||||||
avatarUrl={entry.avatarUrl}
|
|
||||||
name={entry.name}
|
|
||||||
size={16}
|
|
||||||
radius="xl"
|
|
||||||
style={{ flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
<span className={cellClasses.personChipName}>{entry.name}</span>
|
|
||||||
</span>
|
|
||||||
));
|
|
||||||
return (
|
|
||||||
<BadgeOverflowList
|
|
||||||
chips={chips}
|
|
||||||
measureKey={entries.map((e) => `${e.id}:${e.name}`).join("|")}
|
|
||||||
tooltipLabel={entries.map((e) => e.name).join(", ")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { CSSProperties } from "react";
|
|||||||
|
|
||||||
const colorMap: Record<string, { bg: string; bgDark: string; text: string; textDark: string }> = {
|
const colorMap: Record<string, { bg: string; bgDark: string; text: string; textDark: string }> = {
|
||||||
gray: { bg: "#f1f3f5", bgDark: "#373a40", text: "#495057", textDark: "#ced4da" },
|
gray: { bg: "#f1f3f5", bgDark: "#373a40", text: "#495057", textDark: "#ced4da" },
|
||||||
red: { bg: "#ffe3e3", bgDark: "#4a1a1a", text: "#c92a2a", textDark: "#ffa8a8" },
|
red: { bg: "#ffe3e3", bgDark: "#4a1a1a", text: "#bf2020", textDark: "#ffa8a8" },
|
||||||
pink: { bg: "#ffdeeb", bgDark: "#4a1a2e", text: "#a61e4d", textDark: "#faa2c1" },
|
pink: { bg: "#ffdeeb", bgDark: "#4a1a2e", text: "#a61e4d", textDark: "#faa2c1" },
|
||||||
grape: { bg: "#f3d9fa", bgDark: "#3b1a4a", text: "#862e9c", textDark: "#e599f7" },
|
grape: { bg: "#f3d9fa", bgDark: "#3b1a4a", text: "#862e9c", textDark: "#e599f7" },
|
||||||
violet: { bg: "#e5dbff", bgDark: "#2b1a4a", text: "#5f3dc4", textDark: "#b197fc" },
|
violet: { bg: "#e5dbff", bgDark: "#2b1a4a", text: "#5f3dc4", textDark: "#b197fc" },
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import { UserRef } from "@/ee/base/types/base.types";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||||
|
import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow";
|
||||||
|
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||||
|
|
||||||
|
type PersonReadListProps = {
|
||||||
|
personIds: string[];
|
||||||
|
users: Record<string, UserRef>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PersonReadList({ personIds, users }: PersonReadListProps) {
|
||||||
|
const entries = personIds.map((id) => ({
|
||||||
|
id,
|
||||||
|
name: users[id]?.name ?? id.substring(0, 8),
|
||||||
|
avatarUrl: users[id]?.avatarUrl ?? "",
|
||||||
|
}));
|
||||||
|
const chips = entries.map((entry) => (
|
||||||
|
<span
|
||||||
|
key={entry.id}
|
||||||
|
className={clsx(cellClasses.badge, cellClasses.personChip)}
|
||||||
|
>
|
||||||
|
<CustomAvatar
|
||||||
|
avatarUrl={entry.avatarUrl}
|
||||||
|
name={entry.name}
|
||||||
|
size={16}
|
||||||
|
radius="xl"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<span className={cellClasses.personChipName}>{entry.name}</span>
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
return (
|
||||||
|
<BadgeOverflowList
|
||||||
|
chips={chips}
|
||||||
|
measureKey={entries.map((e) => `${e.id}:${e.name}`).join("|")}
|
||||||
|
tooltipLabel={entries.map((e) => e.name).join(", ")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||||
|
import { extractClosestEdge, type Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
||||||
|
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
|
||||||
|
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
|
||||||
|
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash";
|
||||||
|
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
|
||||||
|
import { IBase, IBaseRow, IBaseView, FilterGroup, KANBAN_CARD_DRAG_TYPE, KANBAN_COLUMN_DRAG_TYPE } from "@/ee/base/types/base.types";
|
||||||
|
import { useKanbanColumns } from "@/ee/base/hooks/use-kanban-columns";
|
||||||
|
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
|
||||||
|
import { useKanbanMoveCardMutation, useBaseRowGroupCountsQuery } from "@/ee/base/queries/base-row-query";
|
||||||
|
import { buildColumnFilter } from "@/ee/base/services/kanban-column-filter";
|
||||||
|
import { resolveCardDrop } from "@/ee/base/hooks/use-kanban-card-drop";
|
||||||
|
import { useKanbanBoardAutoScroll } from "@/ee/base/hooks/use-kanban-autoscroll";
|
||||||
|
import { useRowDetailModal } from "@/ee/base/hooks/use-row-detail-modal";
|
||||||
|
import { KanbanColumn } from "@/ee/base/components/kanban/kanban-column";
|
||||||
|
import { KanbanEmptyState } from "@/ee/base/components/kanban/kanban-empty-state";
|
||||||
|
import classes from "@/ee/base/styles/kanban.module.css";
|
||||||
|
|
||||||
|
type BaseKanbanProps = {
|
||||||
|
base: IBase;
|
||||||
|
view: IBaseView;
|
||||||
|
pageId: string;
|
||||||
|
embedded?: boolean;
|
||||||
|
editable: boolean;
|
||||||
|
viewFilter: FilterGroup | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BaseKanban({ base, view, pageId, embedded, editable, viewFilter }: BaseKanbanProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { groupByPropertyId, columns, hasValidGroupBy } = useKanbanColumns(base, view);
|
||||||
|
const updateView = useUpdateViewMutation();
|
||||||
|
const moveCard = useKanbanMoveCardMutation();
|
||||||
|
const { openRow } = useRowDetailModal();
|
||||||
|
|
||||||
|
const { data: groupCounts } = useBaseRowGroupCountsQuery(pageId, groupByPropertyId, viewFilter, undefined);
|
||||||
|
|
||||||
|
const openRowRef = useRef(openRow);
|
||||||
|
useLayoutEffect(() => { openRowRef.current = openRow; });
|
||||||
|
const handleOpenRow = useCallback((id: string) => openRowRef.current(id), []);
|
||||||
|
|
||||||
|
const boardRef = useRef<HTMLDivElement>(null);
|
||||||
|
useKanbanBoardAutoScroll(boardRef, pageId);
|
||||||
|
|
||||||
|
const cardRefs = useRef<Map<string, { columnKey: string; el: HTMLDivElement }>>(new Map());
|
||||||
|
|
||||||
|
const registerCardRef = useCallback((rowId: string, columnKey: string, el: HTMLDivElement | null) => {
|
||||||
|
if (el) {
|
||||||
|
cardRefs.current.set(rowId, { columnKey, el });
|
||||||
|
} else {
|
||||||
|
cardRefs.current.delete(rowId);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const columnRows = useRef<Map<string, IBaseRow[]>>(new Map());
|
||||||
|
|
||||||
|
const registerColumnRows = useCallback((key: string, rows: IBaseRow[]) => {
|
||||||
|
columnRows.current.set(key, rows);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hideColumn = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
const next = Array.from(new Set([...(view.config?.hiddenChoiceIds ?? []), key]));
|
||||||
|
updateView.mutate({ viewId: view.id, pageId, config: { hiddenChoiceIds: next } });
|
||||||
|
},
|
||||||
|
[updateView, view.id, view.config?.hiddenChoiceIds, pageId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onCardDropRef = useRef<(args: {
|
||||||
|
draggedRowId: string;
|
||||||
|
sourceColumnKey: string;
|
||||||
|
targetColumnKey: string;
|
||||||
|
targetRowId: string | null;
|
||||||
|
edge: Edge | null;
|
||||||
|
}) => void>(() => {});
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
onCardDropRef.current = ({ draggedRowId, sourceColumnKey, targetColumnKey, targetRowId, edge }) => {
|
||||||
|
if (!groupByPropertyId) return;
|
||||||
|
const targetColumnRows = columnRows.current.get(targetColumnKey) ?? [];
|
||||||
|
const result = resolveCardDrop({
|
||||||
|
draggedRowId,
|
||||||
|
targetRowId,
|
||||||
|
edge: edge === "left" || edge === "right" ? null : edge,
|
||||||
|
targetColumnKey,
|
||||||
|
sourceColumnKey,
|
||||||
|
targetColumnRows,
|
||||||
|
});
|
||||||
|
if (!result) return;
|
||||||
|
const sourceFilter = buildColumnFilter(viewFilter, groupByPropertyId, sourceColumnKey);
|
||||||
|
const destFilter = buildColumnFilter(viewFilter, groupByPropertyId, targetColumnKey);
|
||||||
|
moveCard.mutate({
|
||||||
|
pageId,
|
||||||
|
rowId: draggedRowId,
|
||||||
|
sourceColumnFilter: sourceFilter,
|
||||||
|
destColumnFilter: destFilter,
|
||||||
|
columnChanged: result.columnChanged,
|
||||||
|
groupByPropertyId,
|
||||||
|
sourceColumnKey,
|
||||||
|
destColumnKey: targetColumnKey,
|
||||||
|
destChoiceValue: result.destChoiceValue,
|
||||||
|
position: result.position,
|
||||||
|
viewFilter,
|
||||||
|
});
|
||||||
|
const el = cardRefs.current.get(draggedRowId)?.el;
|
||||||
|
if (el) triggerPostMoveFlash(el);
|
||||||
|
const targetColumnName = columns.find((c) => c.key === targetColumnKey)?.name ?? "";
|
||||||
|
liveRegion.announce(t("Moved card to {{column}}", { column: targetColumnName }));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return monitorForElements({
|
||||||
|
canMonitor: ({ source }) =>
|
||||||
|
source.data?.type === KANBAN_CARD_DRAG_TYPE && source.data?.pageId === pageId,
|
||||||
|
onDrop: ({ location, source }) => {
|
||||||
|
const target = location.current.dropTargets[0];
|
||||||
|
if (!target) return;
|
||||||
|
const draggedRowId = source.data.rowId as string;
|
||||||
|
const sourceColumnKey = source.data.columnKey as string;
|
||||||
|
const targetColumnKey = target.data.columnKey as string;
|
||||||
|
const isColumnBody = target.data.isColumnBody === true;
|
||||||
|
const targetRowId = isColumnBody ? null : (target.data.rowId as string);
|
||||||
|
const edge = isColumnBody ? null : extractClosestEdge(target.data);
|
||||||
|
onCardDropRef.current({ draggedRowId, sourceColumnKey, targetColumnKey, targetRowId, edge });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [pageId]);
|
||||||
|
|
||||||
|
const onColumnDropRef = useRef<(args: {
|
||||||
|
sourceColumnKey: string;
|
||||||
|
targetColumnKey: string;
|
||||||
|
edge: Edge | null;
|
||||||
|
}) => void>(() => {});
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
onColumnDropRef.current = ({ sourceColumnKey, targetColumnKey, edge }) => {
|
||||||
|
const fullOrder: string[] = view.config?.choiceOrder?.length
|
||||||
|
? view.config.choiceOrder
|
||||||
|
: columns.map((c) => c.key);
|
||||||
|
|
||||||
|
const startIndex = fullOrder.indexOf(sourceColumnKey);
|
||||||
|
const indexOfTarget = fullOrder.indexOf(targetColumnKey);
|
||||||
|
|
||||||
|
if (startIndex === -1 || indexOfTarget === -1) {
|
||||||
|
const visibleKeys = columns.map((c) => c.key);
|
||||||
|
const visStart = visibleKeys.indexOf(sourceColumnKey);
|
||||||
|
const visTarget = visibleKeys.indexOf(targetColumnKey);
|
||||||
|
if (visStart === -1 || visTarget === -1) return;
|
||||||
|
const finishIndex = getReorderDestinationIndex({
|
||||||
|
startIndex: visStart,
|
||||||
|
indexOfTarget: visTarget,
|
||||||
|
closestEdgeOfTarget: edge,
|
||||||
|
axis: "horizontal",
|
||||||
|
});
|
||||||
|
if (finishIndex === visStart) return;
|
||||||
|
const reorderedVisible = reorder({ list: visibleKeys, startIndex: visStart, finishIndex });
|
||||||
|
updateView.mutate({ viewId: view.id, pageId, config: { choiceOrder: [...reorderedVisible, ...(view.config?.hiddenChoiceIds ?? [])] } });
|
||||||
|
} else {
|
||||||
|
const finishIndex = getReorderDestinationIndex({
|
||||||
|
startIndex,
|
||||||
|
indexOfTarget,
|
||||||
|
closestEdgeOfTarget: edge,
|
||||||
|
axis: "horizontal",
|
||||||
|
});
|
||||||
|
if (finishIndex === startIndex) return;
|
||||||
|
const newChoiceOrder = reorder({ list: fullOrder, startIndex, finishIndex });
|
||||||
|
updateView.mutate({ viewId: view.id, pageId, config: { choiceOrder: newChoiceOrder } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetColumnName = columns.find((c) => c.key === targetColumnKey)?.name ?? "";
|
||||||
|
liveRegion.announce(t("Moved column to {{column}}", { column: targetColumnName }));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return monitorForElements({
|
||||||
|
canMonitor: ({ source }) =>
|
||||||
|
source.data?.type === KANBAN_COLUMN_DRAG_TYPE && source.data?.pageId === pageId,
|
||||||
|
onDrop: ({ location, source }) => {
|
||||||
|
const target = location.current.dropTargets[0];
|
||||||
|
if (!target) return;
|
||||||
|
const sourceColumnKey = source.data.columnKey as string;
|
||||||
|
const targetColumnKey = target.data.columnKey as string;
|
||||||
|
const edge = extractClosestEdge(target.data);
|
||||||
|
onColumnDropRef.current({ sourceColumnKey, targetColumnKey, edge });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [pageId]);
|
||||||
|
|
||||||
|
if (!hasValidGroupBy) {
|
||||||
|
return <KanbanEmptyState base={base} view={view} pageId={pageId} editable={editable} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={boardRef}
|
||||||
|
className={clsx(classes.board, embedded ? classes.boardEmbed : classes.boardFullPage)}
|
||||||
|
>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<KanbanColumn
|
||||||
|
key={column.key}
|
||||||
|
base={base}
|
||||||
|
view={view}
|
||||||
|
pageId={pageId}
|
||||||
|
column={column}
|
||||||
|
viewFilter={viewFilter}
|
||||||
|
groupByPropertyId={groupByPropertyId!}
|
||||||
|
count={groupCounts ? (groupCounts[column.key] ?? 0) : undefined}
|
||||||
|
canEdit={editable}
|
||||||
|
onOpenRow={handleOpenRow}
|
||||||
|
onHide={hideColumn}
|
||||||
|
registerCardRef={registerCardRef}
|
||||||
|
registerColumnRows={registerColumnRows}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { Text, Badge, Tooltip, Group } from "@mantine/core";
|
||||||
|
import { IconCheck, IconFileDescription } from "@tabler/icons-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { sanitizeUrl } from "@docmost/editor-ext";
|
||||||
|
import {
|
||||||
|
IBaseProperty,
|
||||||
|
SelectTypeOptions,
|
||||||
|
NumberTypeOptions,
|
||||||
|
DateTypeOptions,
|
||||||
|
isFormulaErrorCell,
|
||||||
|
} from "@/ee/base/types/base.types";
|
||||||
|
import { choiceColor } from "@/ee/base/components/cells/choice-color";
|
||||||
|
import { ChoiceBadge } from "@/ee/base/components/cells/choice-badge";
|
||||||
|
import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow";
|
||||||
|
import { PersonReadList } from "@/ee/base/components/cells/person-read-list";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||||
|
import { useReferenceStore, useResolvePage } from "@/ee/base/reference/reference-store";
|
||||||
|
import {
|
||||||
|
formatNumber,
|
||||||
|
formatDateDisplay,
|
||||||
|
formatTimestamp,
|
||||||
|
formatLongTextPreview,
|
||||||
|
} from "@/ee/base/formatters/cell-formatters";
|
||||||
|
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils";
|
||||||
|
import { FileValue } from "@/ee/base/components/cells/cell-file";
|
||||||
|
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||||
|
|
||||||
|
type CardFieldProps = {
|
||||||
|
property: IBaseProperty;
|
||||||
|
value: unknown;
|
||||||
|
pageId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CardField({ property, value, pageId }: CardFieldProps) {
|
||||||
|
if (value === null || value === undefined || value === "") return null;
|
||||||
|
if (Array.isArray(value) && value.length === 0) return null;
|
||||||
|
|
||||||
|
switch (property.type) {
|
||||||
|
case "text":
|
||||||
|
return <TextField value={value} />;
|
||||||
|
case "longText":
|
||||||
|
return <LongTextField value={value} />;
|
||||||
|
case "number":
|
||||||
|
return <NumberField value={value} property={property} />;
|
||||||
|
case "select":
|
||||||
|
case "status":
|
||||||
|
return <SelectField value={value} property={property} />;
|
||||||
|
case "multiSelect":
|
||||||
|
return <MultiSelectField value={value} property={property} />;
|
||||||
|
case "date":
|
||||||
|
return <DateField value={value} property={property} />;
|
||||||
|
case "createdAt":
|
||||||
|
case "lastEditedAt":
|
||||||
|
return <TimestampField value={value} />;
|
||||||
|
case "person":
|
||||||
|
return <PersonField value={value} pageId={pageId} />;
|
||||||
|
case "lastEditedBy":
|
||||||
|
return <LastEditedByField value={value} pageId={pageId} />;
|
||||||
|
case "file":
|
||||||
|
return <FileField value={value} />;
|
||||||
|
case "page":
|
||||||
|
return <PageField value={value} basePageId={pageId} propertyPageId={property.pageId} />;
|
||||||
|
case "checkbox":
|
||||||
|
return <CheckboxField value={value} />;
|
||||||
|
case "url":
|
||||||
|
return <UrlField value={value} />;
|
||||||
|
case "email":
|
||||||
|
return <EmailField value={value} />;
|
||||||
|
case "formula":
|
||||||
|
return <FormulaField value={value} property={property} />;
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Text size="xs" lineClamp={1}>
|
||||||
|
{String(value)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextField({ value }: { value: unknown }) {
|
||||||
|
const text = typeof value === "string" ? value : String(value);
|
||||||
|
if (!text) return null;
|
||||||
|
return (
|
||||||
|
<Text size="sm" lineClamp={2}>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LongTextField({ value }: { value: unknown }) {
|
||||||
|
const preview = formatLongTextPreview(typeof value === "string" ? value : undefined);
|
||||||
|
if (!preview) return null;
|
||||||
|
return (
|
||||||
|
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||||
|
{preview}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberField({ value, property }: { value: unknown; property: IBaseProperty }) {
|
||||||
|
const num = typeof value === "number" ? value : null;
|
||||||
|
if (num === null) return null;
|
||||||
|
const formatted = formatNumber(num, property.typeOptions as NumberTypeOptions | undefined);
|
||||||
|
if (!formatted) return null;
|
||||||
|
return <Text size="sm">{formatted}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectField({ value, property }: { value: unknown; property: IBaseProperty }) {
|
||||||
|
const choices = (property.typeOptions as SelectTypeOptions | undefined)?.choices ?? [];
|
||||||
|
const selectedId = typeof value === "string" ? value : null;
|
||||||
|
const choice = choices.find((c) => c.id === selectedId);
|
||||||
|
if (!choice) return null;
|
||||||
|
return (
|
||||||
|
<ChoiceBadge
|
||||||
|
name={choice.name}
|
||||||
|
style={{ ...choiceColor(choice.color), alignSelf: "flex-start" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MultiSelectField({ value, property }: { value: unknown; property: IBaseProperty }) {
|
||||||
|
const choices = (property.typeOptions as SelectTypeOptions | undefined)?.choices ?? [];
|
||||||
|
const selectedIds = Array.isArray(value) ? (value as string[]) : [];
|
||||||
|
const selectedChoices = choices.filter((c) => selectedIds.includes(c.id));
|
||||||
|
if (selectedChoices.length === 0) return null;
|
||||||
|
const chips = selectedChoices.map((choice) => (
|
||||||
|
<span key={choice.id} className={cellClasses.badge} style={choiceColor(choice.color)}>
|
||||||
|
{choice.name}
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
return (
|
||||||
|
<BadgeOverflowList
|
||||||
|
chips={chips}
|
||||||
|
measureKey={selectedChoices.map((c) => `${c.id}:${c.name}`).join("|")}
|
||||||
|
tooltipLabel={selectedChoices.map((c) => c.name).join(", ")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DateField({ value, property }: { value: unknown; property: IBaseProperty }) {
|
||||||
|
const dateStr = typeof value === "string" ? value : null;
|
||||||
|
const formatted = formatDateDisplay(dateStr, property.typeOptions as DateTypeOptions | undefined);
|
||||||
|
if (!formatted) return null;
|
||||||
|
return (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{formatted}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimestampField({ value }: { value: unknown }) {
|
||||||
|
const formatted = formatTimestamp(typeof value === "string" ? value : null);
|
||||||
|
if (!formatted) return null;
|
||||||
|
return (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{formatted}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonField({ value, pageId }: { value: unknown; pageId: string }) {
|
||||||
|
const store = useReferenceStore(pageId);
|
||||||
|
const personIds = Array.isArray(value)
|
||||||
|
? (value as string[])
|
||||||
|
: typeof value === "string"
|
||||||
|
? [value]
|
||||||
|
: [];
|
||||||
|
if (personIds.length === 0) return null;
|
||||||
|
return <PersonReadList personIds={personIds} users={store.users} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LastEditedByField({ value, pageId }: { value: unknown; pageId: string }) {
|
||||||
|
const userId = typeof value === "string" ? value : null;
|
||||||
|
const store = useReferenceStore(pageId);
|
||||||
|
if (!userId) return null;
|
||||||
|
const user = store.users[userId] ?? null;
|
||||||
|
const name = user?.name ?? userId.substring(0, 8);
|
||||||
|
return (
|
||||||
|
<Group gap={6} wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||||
|
<CustomAvatar avatarUrl={user?.avatarUrl ?? ""} name={name} size={20} radius="xl" />
|
||||||
|
<Tooltip label={name} withinPortal openDelay={400} disabled={!name}>
|
||||||
|
<Text size="xs" truncate>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileField({ value }: { value: unknown }) {
|
||||||
|
const files = Array.isArray(value)
|
||||||
|
? (value as FileValue[]).filter((f) => f && typeof f === "object" && "id" in f && "fileName" in f)
|
||||||
|
: [];
|
||||||
|
if (files.length === 0) return null;
|
||||||
|
const maxVisible = 2;
|
||||||
|
const visible = files.slice(0, maxVisible);
|
||||||
|
const overflow = files.length - maxVisible;
|
||||||
|
return (
|
||||||
|
<div className={cellClasses.fileGroup}>
|
||||||
|
{visible.map((file) => (
|
||||||
|
<span key={file.id} className={cellClasses.fileBadge}>
|
||||||
|
{file.fileName}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{overflow > 0 && <span className={cellClasses.overflowCount}>+{overflow}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageField({
|
||||||
|
value,
|
||||||
|
basePageId,
|
||||||
|
propertyPageId,
|
||||||
|
}: {
|
||||||
|
value: unknown;
|
||||||
|
basePageId: string;
|
||||||
|
propertyPageId: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const pageId = typeof value === "string" && value.length > 0 ? value : null;
|
||||||
|
const resolvedPage = useResolvePage(propertyPageId, pageId);
|
||||||
|
|
||||||
|
if (!pageId) return null;
|
||||||
|
if (resolvedPage === undefined) return null;
|
||||||
|
|
||||||
|
if (resolvedPage === null) {
|
||||||
|
return (
|
||||||
|
<span className={cellClasses.pageMissing}>
|
||||||
|
<IconFileDescription size={14} />
|
||||||
|
<span>Page not found</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = getPageTitle(resolvedPage.title, undefined, t);
|
||||||
|
const spaceSlug = resolvedPage.space?.slug ?? "";
|
||||||
|
const url = buildPageUrl(spaceSlug, resolvedPage.slugId, title);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip label={title} withinPortal openDelay={400} disabled={!title}>
|
||||||
|
<Link
|
||||||
|
to={url}
|
||||||
|
className={cellClasses.pagePill}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{resolvedPage.icon ? (
|
||||||
|
<span className={cellClasses.pagePillIcon}>{resolvedPage.icon}</span>
|
||||||
|
) : (
|
||||||
|
<IconFileDescription size={14} className={cellClasses.pagePillIconFallback} />
|
||||||
|
)}
|
||||||
|
<span className={cellClasses.pagePillText}>{title}</span>
|
||||||
|
</Link>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckboxField({ value }: { value: unknown }) {
|
||||||
|
if (value !== true) return null;
|
||||||
|
return <IconCheck size={14} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UrlField({ value }: { value: unknown }) {
|
||||||
|
const displayValue = typeof value === "string" ? value : "";
|
||||||
|
if (!displayValue) return null;
|
||||||
|
const safeHref = sanitizeUrl(displayValue);
|
||||||
|
if (!safeHref) {
|
||||||
|
return (
|
||||||
|
<Text size="xs" lineClamp={1}>
|
||||||
|
{displayValue}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Tooltip label={displayValue} multiline withinPortal openDelay={400} maw={420}>
|
||||||
|
<a
|
||||||
|
className={cellClasses.urlLink}
|
||||||
|
href={safeHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ fontSize: "var(--mantine-font-size-xs)" }}
|
||||||
|
>
|
||||||
|
{displayValue}
|
||||||
|
</a>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmailField({ value }: { value: unknown }) {
|
||||||
|
const displayValue = typeof value === "string" ? value : "";
|
||||||
|
if (!displayValue) return null;
|
||||||
|
return (
|
||||||
|
<Tooltip label={displayValue} multiline withinPortal openDelay={400} maw={420}>
|
||||||
|
<a
|
||||||
|
className={cellClasses.emailLink}
|
||||||
|
href={`mailto:${displayValue}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ fontSize: "var(--mantine-font-size-xs)" }}
|
||||||
|
>
|
||||||
|
{displayValue}
|
||||||
|
</a>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormulaField({ value, property }: { value: unknown; property: IBaseProperty }) {
|
||||||
|
if (isFormulaErrorCell(value)) {
|
||||||
|
return (
|
||||||
|
<Tooltip label={`${value.__err}: ${value.msg}`} withinPortal>
|
||||||
|
<Badge color="red" variant="light" size="sm">
|
||||||
|
#ERROR
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = (property.typeOptions ?? {}) as { resultType?: string };
|
||||||
|
const resultType = opts.resultType ?? "null";
|
||||||
|
|
||||||
|
if (resultType === "number") {
|
||||||
|
return <NumberField value={value} property={property} />;
|
||||||
|
}
|
||||||
|
if (resultType === "boolean") {
|
||||||
|
return <CheckboxField value={value} />;
|
||||||
|
}
|
||||||
|
if (resultType === "date") {
|
||||||
|
return <DateField value={value} property={property} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = typeof value === "string" ? value : value != null ? String(value) : null;
|
||||||
|
if (!text) return null;
|
||||||
|
return (
|
||||||
|
<Text size="sm" lineClamp={2}>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { IconPlus } from "@tabler/icons-react";
|
||||||
|
import classes from "@/ee/base/styles/kanban.module.css";
|
||||||
|
|
||||||
|
type KanbanAddCardButtonProps = {
|
||||||
|
onAddCard: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function KanbanAddCardButton({ onAddCard }: KanbanAddCardButtonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classes.addCard}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onAddCard}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onAddCard();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconPlus size={16} />
|
||||||
|
{t("New row")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { Popover, Switch, Stack, Text, Group, UnstyledButton, ScrollArea } from "@mantine/core";
|
||||||
|
import { IconGripVertical, type IconLetterT } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { IBase, IBaseProperty, IBaseView } from "@/ee/base/types/base.types";
|
||||||
|
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
|
||||||
|
import { propertyTypes } from "@/ee/base/components/property/property-type-picker";
|
||||||
|
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
|
||||||
|
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||||
|
import {
|
||||||
|
draggable,
|
||||||
|
dropTargetForElements,
|
||||||
|
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||||
|
import {
|
||||||
|
attachClosestEdge,
|
||||||
|
extractClosestEdge,
|
||||||
|
type Edge,
|
||||||
|
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
||||||
|
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
|
||||||
|
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
|
||||||
|
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||||
|
import propClasses from "@/ee/base/styles/property.module.css";
|
||||||
|
|
||||||
|
const DRAG_TYPE = "base-card-property";
|
||||||
|
|
||||||
|
type KanbanCardPropertiesProps = {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
base: IBase;
|
||||||
|
view: IBaseView;
|
||||||
|
pageId: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function KanbanCardProperties({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
base,
|
||||||
|
view,
|
||||||
|
pageId,
|
||||||
|
children,
|
||||||
|
}: KanbanCardPropertiesProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const updateView = useUpdateViewMutation();
|
||||||
|
|
||||||
|
const nonPrimaryProperties = base.properties.filter((p) => !p.isPrimary);
|
||||||
|
const visibleIds = view.config?.visiblePropertyIds ?? [];
|
||||||
|
|
||||||
|
const savedOrder = view.config?.propertyOrder ?? [];
|
||||||
|
const orderedProperties = [
|
||||||
|
...savedOrder
|
||||||
|
.map((id) => nonPrimaryProperties.find((p) => p.id === id))
|
||||||
|
.filter((p): p is IBaseProperty => p !== undefined),
|
||||||
|
...nonPrimaryProperties.filter((p) => !savedOrder.includes(p.id)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const primaryProperty = base.properties.find((p) => p.isPrimary);
|
||||||
|
const PrimaryIcon = primaryProperty
|
||||||
|
? propertyTypes.find((pt) => pt.type === primaryProperty.type)?.icon
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const handleToggle = useCallback(
|
||||||
|
(propertyId: string, checked: boolean) => {
|
||||||
|
const next = checked
|
||||||
|
? [...visibleIds, propertyId]
|
||||||
|
: visibleIds.filter((id) => id !== propertyId);
|
||||||
|
updateView.mutate({ viewId: view.id, pageId, config: { visiblePropertyIds: next } });
|
||||||
|
},
|
||||||
|
[updateView, view.id, visibleIds, pageId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReorder = useCallback(
|
||||||
|
(activeId: string, targetId: string, edge: Edge) => {
|
||||||
|
const startIndex = orderedProperties.findIndex((p) => p.id === activeId);
|
||||||
|
const indexOfTarget = orderedProperties.findIndex((p) => p.id === targetId);
|
||||||
|
if (startIndex === -1 || indexOfTarget === -1) return;
|
||||||
|
const finishIndex = getReorderDestinationIndex({
|
||||||
|
startIndex,
|
||||||
|
indexOfTarget,
|
||||||
|
closestEdgeOfTarget: edge,
|
||||||
|
axis: "vertical",
|
||||||
|
});
|
||||||
|
if (finishIndex === startIndex) return;
|
||||||
|
const reordered = reorder({ list: orderedProperties, startIndex, finishIndex });
|
||||||
|
updateView.mutate({
|
||||||
|
viewId: view.id,
|
||||||
|
pageId,
|
||||||
|
config: { propertyOrder: reordered.map((p) => p.id) },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[orderedProperties, updateView, view.id, pageId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
opened={opened}
|
||||||
|
onChange={(o) => {
|
||||||
|
if (!o) onClose();
|
||||||
|
}}
|
||||||
|
onClose={onClose}
|
||||||
|
position="bottom-end"
|
||||||
|
shadow="md"
|
||||||
|
width={260}
|
||||||
|
trapFocus
|
||||||
|
closeOnEscape
|
||||||
|
closeOnClickOutside
|
||||||
|
withinPortal
|
||||||
|
>
|
||||||
|
<Popover.Target>{children}</Popover.Target>
|
||||||
|
<Popover.Dropdown p="xs">
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Group justify="space-between" px={4} py={2}>
|
||||||
|
<Text size="xs" fw={600} c="dimmed">
|
||||||
|
{t("Card properties")}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<ScrollArea.Autosize mah="min(60vh, 420px)" scrollbarSize={6} offsetScrollbars>
|
||||||
|
<Stack gap={0}>
|
||||||
|
{primaryProperty && (
|
||||||
|
<div className={cellClasses.menuItem} style={{ paddingLeft: 4, cursor: "default" }}>
|
||||||
|
<div className={propClasses.dragHandle} style={{ visibility: "hidden" }}>
|
||||||
|
<IconGripVertical size={14} />
|
||||||
|
</div>
|
||||||
|
<Group gap={8} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{PrimaryIcon && <PrimaryIcon size={14} style={{ flexShrink: 0 }} />}
|
||||||
|
<Text size="sm" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{primaryProperty.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Switch
|
||||||
|
size="xs"
|
||||||
|
checked
|
||||||
|
disabled
|
||||||
|
onChange={() => {}}
|
||||||
|
styles={{ track: { cursor: "default" } }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{orderedProperties.map((p) => {
|
||||||
|
const isVisible = visibleIds.includes(p.id);
|
||||||
|
const typeConfig = propertyTypes.find((pt) => pt.type === p.type);
|
||||||
|
const TypeIcon = typeConfig?.icon;
|
||||||
|
return (
|
||||||
|
<SortablePropertyRow
|
||||||
|
key={p.id}
|
||||||
|
property={p}
|
||||||
|
isVisible={isVisible}
|
||||||
|
TypeIcon={TypeIcon}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
onReorder={handleReorder}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Stack>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortablePropertyRowProps = {
|
||||||
|
property: IBaseProperty;
|
||||||
|
isVisible: boolean;
|
||||||
|
TypeIcon: typeof IconLetterT | undefined;
|
||||||
|
onToggle: (propertyId: string, checked: boolean) => void;
|
||||||
|
onReorder: (activeId: string, targetId: string, edge: Edge) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SortablePropertyRow({
|
||||||
|
property,
|
||||||
|
isVisible,
|
||||||
|
TypeIcon,
|
||||||
|
onToggle,
|
||||||
|
onReorder,
|
||||||
|
}: SortablePropertyRowProps) {
|
||||||
|
const rowRef = useRef<HTMLDivElement>(null);
|
||||||
|
const handleRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
||||||
|
|
||||||
|
const onReorderRef = useRef(onReorder);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
onReorderRef.current = onReorder;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const row = rowRef.current;
|
||||||
|
const handle = handleRef.current;
|
||||||
|
if (!row || !handle) return;
|
||||||
|
return combine(
|
||||||
|
draggable({
|
||||||
|
element: row,
|
||||||
|
dragHandle: handle,
|
||||||
|
getInitialData: () => ({ type: DRAG_TYPE, propertyId: property.id }),
|
||||||
|
onDragStart: () => setIsDragging(true),
|
||||||
|
onDrop: () => setIsDragging(false),
|
||||||
|
}),
|
||||||
|
dropTargetForElements({
|
||||||
|
element: row,
|
||||||
|
canDrop: ({ source }) =>
|
||||||
|
source.data.type === DRAG_TYPE && source.data.propertyId !== property.id,
|
||||||
|
getData: ({ input, element }) =>
|
||||||
|
attachClosestEdge(
|
||||||
|
{ propertyId: property.id },
|
||||||
|
{ input, element, allowedEdges: ["top", "bottom"] },
|
||||||
|
),
|
||||||
|
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
|
||||||
|
onDragLeave: () => setClosestEdge(null),
|
||||||
|
onDrop: ({ source, self }) => {
|
||||||
|
setClosestEdge(null);
|
||||||
|
const edge = extractClosestEdge(self.data);
|
||||||
|
if (!edge) return;
|
||||||
|
onReorderRef.current(source.data.propertyId as string, property.id, edge);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [property.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rowRef}
|
||||||
|
style={{ position: "relative", opacity: isDragging ? 0.4 : 1 }}
|
||||||
|
>
|
||||||
|
<UnstyledButton
|
||||||
|
className={cellClasses.menuItem}
|
||||||
|
onClick={() => onToggle(property.id, !isVisible)}
|
||||||
|
style={{ paddingLeft: 4 }}
|
||||||
|
>
|
||||||
|
<div ref={handleRef} className={propClasses.dragHandle} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<IconGripVertical size={14} style={{ opacity: 0.4 }} />
|
||||||
|
</div>
|
||||||
|
<Group gap={8} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{TypeIcon && <TypeIcon size={14} style={{ flexShrink: 0 }} />}
|
||||||
|
<Text size="sm" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{property.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Switch
|
||||||
|
size="xs"
|
||||||
|
checked={isVisible}
|
||||||
|
onChange={() => {}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
styles={{ track: { cursor: "pointer" } }}
|
||||||
|
/>
|
||||||
|
</UnstyledButton>
|
||||||
|
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { forwardRef, useCallback, useRef } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { IBase, IBaseRow, IBaseView } from "@/ee/base/types/base.types";
|
||||||
|
import { CardField } from "@/ee/base/components/kanban/card-field/card-field";
|
||||||
|
import { useKanbanCardDnd } from "@/ee/base/hooks/use-kanban-card-dnd";
|
||||||
|
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
|
||||||
|
import classes from "@/ee/base/styles/kanban.module.css";
|
||||||
|
|
||||||
|
type KanbanCardProps = {
|
||||||
|
base: IBase;
|
||||||
|
view: IBaseView;
|
||||||
|
row: IBaseRow;
|
||||||
|
columnKey: string;
|
||||||
|
onOpen: (rowId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KanbanCard = forwardRef<HTMLDivElement, KanbanCardProps>(
|
||||||
|
function KanbanCard({ base, view, row, columnKey, onOpen }, ref) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const primary = base.properties.find((p) => p.isPrimary);
|
||||||
|
const title = primary ? (row.cells[primary.id] as string | undefined) : undefined;
|
||||||
|
|
||||||
|
const visibleIds = view.config?.visiblePropertyIds ?? [];
|
||||||
|
const propertyOrder = view.config?.propertyOrder;
|
||||||
|
|
||||||
|
const cardProps = base.properties.filter(
|
||||||
|
(p) => visibleIds.includes(p.id) && !p.isPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (propertyOrder) {
|
||||||
|
cardProps.sort(
|
||||||
|
(a, b) => propertyOrder.indexOf(a.id) - propertyOrder.indexOf(b.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const setCardEl = useCallback(
|
||||||
|
(node: HTMLDivElement | null) => {
|
||||||
|
cardRef.current = node;
|
||||||
|
if (typeof ref === "function") ref(node);
|
||||||
|
else if (ref) ref.current = node;
|
||||||
|
},
|
||||||
|
[ref],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { closestEdge, isDragging } = useKanbanCardDnd({
|
||||||
|
cardRef,
|
||||||
|
rowId: row.id,
|
||||||
|
columnKey,
|
||||||
|
pageId: base.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setCardEl}
|
||||||
|
className={clsx(classes.card, isDragging && classes.cardDragging)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => onOpen(row.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onOpen(row.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{closestEdge === "top" && <BaseDropEdgeIndicator edge="top" />}
|
||||||
|
<div className={clsx(classes.cardTitle, !title && classes.cardUntitled)}>
|
||||||
|
{title || t("Untitled")}
|
||||||
|
</div>
|
||||||
|
{cardProps.map((property) => (
|
||||||
|
<CardField
|
||||||
|
key={property.id}
|
||||||
|
property={property}
|
||||||
|
value={row.cells[property.id]}
|
||||||
|
pageId={base.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{closestEdge === "bottom" && <BaseDropEdgeIndicator edge="bottom" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActionIcon, Menu, Text } from "@mantine/core";
|
||||||
|
import { IconDots, IconPlus, IconGripVertical } from "@tabler/icons-react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { KanbanColumn } from "@/ee/base/types/base.types";
|
||||||
|
import { choiceColor } from "@/ee/base/components/cells/choice-color";
|
||||||
|
import { useKanbanColumnDnd } from "@/ee/base/hooks/use-kanban-column-dnd";
|
||||||
|
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
|
||||||
|
import classes from "@/ee/base/styles/kanban.module.css";
|
||||||
|
|
||||||
|
type KanbanColumnHeaderProps = {
|
||||||
|
column: KanbanColumn;
|
||||||
|
pageId: string;
|
||||||
|
count?: number;
|
||||||
|
canEdit: boolean;
|
||||||
|
onHide: () => void;
|
||||||
|
onAddCard: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function KanbanColumnHeader({ column, pageId, count, canEdit, onHide, onAddCard }: KanbanColumnHeaderProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const dotColor = column.color
|
||||||
|
? choiceColor(column.color).color as string
|
||||||
|
: "light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3))";
|
||||||
|
|
||||||
|
const headerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const handleRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { closestEdge, isDragging } = useKanbanColumnDnd({
|
||||||
|
headerRef,
|
||||||
|
handleRef,
|
||||||
|
columnKey: column.key,
|
||||||
|
pageId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={headerRef} className={clsx(classes.columnHeader, isDragging && classes.columnHeaderDragging)}>
|
||||||
|
{canEdit && (
|
||||||
|
<div ref={handleRef} className={classes.columnDragHandle} aria-hidden>
|
||||||
|
<IconGripVertical size={14} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
flexShrink: 0,
|
||||||
|
background: dotColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text fw={600} size="sm" flex={1} truncate>
|
||||||
|
{column.isNoValue ? t("No value") : column.name}
|
||||||
|
</Text>
|
||||||
|
{count !== undefined && <Text className={classes.count}>{count}</Text>}
|
||||||
|
{canEdit && (
|
||||||
|
<>
|
||||||
|
<Menu position="bottom-end" withinPortal>
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon variant="subtle" size="sm" aria-label={t("Column options")}>
|
||||||
|
<IconDots size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item onClick={onHide}>{t("Hide group")}</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
<ActionIcon variant="subtle" size="sm" aria-label={t("Add card")} onClick={onAddCard}>
|
||||||
|
<IconPlus size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||||
|
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||||
|
import { type IBase, type IBaseRow, type IBaseView, type FilterGroup, type KanbanColumn as KanbanColumnType, KANBAN_CARD_DRAG_TYPE } from "@/ee/base/types/base.types";
|
||||||
|
import { buildColumnFilter } from "@/ee/base/services/kanban-column-filter";
|
||||||
|
import { useKanbanColumnAutoScroll } from "@/ee/base/hooks/use-kanban-autoscroll";
|
||||||
|
import { useBaseRowsQuery } from "@/ee/base/queries/base-row-query";
|
||||||
|
import { useKanbanCreateCardMutation } from "@/ee/base/queries/base-row-query";
|
||||||
|
import { KanbanColumnHeader } from "@/ee/base/components/kanban/kanban-column-header";
|
||||||
|
import { KanbanAddCardButton } from "@/ee/base/components/kanban/kanban-add-card-button";
|
||||||
|
import { KanbanCard } from "@/ee/base/components/kanban/kanban-card";
|
||||||
|
import classes from "@/ee/base/styles/kanban.module.css";
|
||||||
|
|
||||||
|
type KanbanColumnProps = {
|
||||||
|
base: IBase;
|
||||||
|
view: IBaseView;
|
||||||
|
pageId: string;
|
||||||
|
column: KanbanColumnType;
|
||||||
|
viewFilter: FilterGroup | undefined;
|
||||||
|
groupByPropertyId: string;
|
||||||
|
count?: number;
|
||||||
|
canEdit: boolean;
|
||||||
|
onOpenRow: (rowId: string) => void;
|
||||||
|
onHide: (columnKey: string) => void;
|
||||||
|
registerCardRef: (rowId: string, columnKey: string, el: HTMLDivElement | null) => void;
|
||||||
|
registerColumnRows: (columnKey: string, rows: IBaseRow[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function KanbanColumn({
|
||||||
|
base,
|
||||||
|
view,
|
||||||
|
pageId,
|
||||||
|
column,
|
||||||
|
viewFilter,
|
||||||
|
groupByPropertyId,
|
||||||
|
count,
|
||||||
|
canEdit,
|
||||||
|
onOpenRow,
|
||||||
|
onHide,
|
||||||
|
registerCardRef,
|
||||||
|
registerColumnRows,
|
||||||
|
}: KanbanColumnProps) {
|
||||||
|
const filter = useMemo(
|
||||||
|
() => buildColumnFilter(viewFilter, groupByPropertyId, column.key),
|
||||||
|
[viewFilter, groupByPropertyId, column.key],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rowsQuery = useBaseRowsQuery(pageId, filter, undefined, undefined);
|
||||||
|
const createCard = useKanbanCreateCardMutation();
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const pages = rowsQuery.data?.pages ?? [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const flat: IBaseRow[] = [];
|
||||||
|
for (const page of pages) {
|
||||||
|
for (const row of page.items) {
|
||||||
|
if (!seen.has(row.id)) {
|
||||||
|
seen.add(row.id);
|
||||||
|
flat.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flat.slice().sort((a, b) =>
|
||||||
|
a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
|
||||||
|
);
|
||||||
|
}, [rowsQuery.data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerColumnRows(column.key, rows);
|
||||||
|
}, [column.key, rows, registerColumnRows]);
|
||||||
|
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
useKanbanColumnAutoScroll(listRef, pageId);
|
||||||
|
|
||||||
|
const pendingScrollRef = useRef<"top" | "bottom" | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const placement = pendingScrollRef.current;
|
||||||
|
if (!placement) return;
|
||||||
|
pendingScrollRef.current = null;
|
||||||
|
const el = listRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollTop = placement === "top" ? 0 : el.scrollHeight;
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listEl = listRef.current;
|
||||||
|
if (!listEl) return;
|
||||||
|
return dropTargetForElements({
|
||||||
|
element: listEl,
|
||||||
|
canDrop: ({ source }) =>
|
||||||
|
source.data.type === KANBAN_CARD_DRAG_TYPE && source.data.pageId === pageId,
|
||||||
|
getData: () => ({ columnKey: column.key, isColumnBody: true }),
|
||||||
|
});
|
||||||
|
}, [column.key, pageId]);
|
||||||
|
|
||||||
|
const onScroll = useCallback(() => {
|
||||||
|
const el = listRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const { scrollHeight, scrollTop, clientHeight } = el;
|
||||||
|
if (
|
||||||
|
scrollHeight - scrollTop - clientHeight < 200 &&
|
||||||
|
rowsQuery.hasNextPage &&
|
||||||
|
!rowsQuery.isFetchingNextPage
|
||||||
|
) {
|
||||||
|
rowsQuery.fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [rowsQuery.hasNextPage, rowsQuery.isFetchingNextPage, rowsQuery.fetchNextPage]);
|
||||||
|
|
||||||
|
const addCard = useCallback(
|
||||||
|
(placement: "top" | "bottom") => {
|
||||||
|
let position: string | undefined;
|
||||||
|
try {
|
||||||
|
position =
|
||||||
|
placement === "top"
|
||||||
|
? generateJitteredKeyBetween(null, rows[0]?.position ?? null)
|
||||||
|
: generateJitteredKeyBetween(rows[rows.length - 1]?.position ?? null, null);
|
||||||
|
} catch {
|
||||||
|
position = undefined;
|
||||||
|
}
|
||||||
|
createCard.mutate(
|
||||||
|
{ pageId, destColumnFilter: filter, groupByPropertyId, columnKey: column.key, position, viewFilter },
|
||||||
|
{
|
||||||
|
onSuccess: (newRow) => {
|
||||||
|
pendingScrollRef.current = placement;
|
||||||
|
onOpenRow(newRow.id);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[createCard, pageId, filter, groupByPropertyId, column.key, viewFilter, onOpenRow, rows],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.column} data-column-key={column.key}>
|
||||||
|
<KanbanColumnHeader
|
||||||
|
column={column}
|
||||||
|
pageId={pageId}
|
||||||
|
count={count}
|
||||||
|
canEdit={canEdit}
|
||||||
|
onHide={() => onHide(column.key)}
|
||||||
|
onAddCard={() => addCard("top")}
|
||||||
|
/>
|
||||||
|
<div className={classes.cardList} ref={listRef} onScroll={onScroll}>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<KanbanCard
|
||||||
|
key={row.id}
|
||||||
|
base={base}
|
||||||
|
view={view}
|
||||||
|
row={row}
|
||||||
|
columnKey={column.key}
|
||||||
|
onOpen={onOpenRow}
|
||||||
|
ref={(el) => registerCardRef(row.id, column.key, el)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{canEdit && <KanbanAddCardButton onAddCard={() => addCard("bottom")} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { Stack, Text, Select, Button } from "@mantine/core";
|
||||||
|
import { v7 as uuid7 } from "uuid";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { IBase, IBaseView } from "@/ee/base/types/base.types";
|
||||||
|
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
|
||||||
|
import { useCreatePropertyMutation } from "@/ee/base/queries/base-property-query";
|
||||||
|
|
||||||
|
type KanbanEmptyStateProps = {
|
||||||
|
base: IBase;
|
||||||
|
view: IBaseView;
|
||||||
|
pageId: string;
|
||||||
|
editable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function KanbanEmptyState({ base, view, pageId, editable }: KanbanEmptyStateProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const updateView = useUpdateViewMutation();
|
||||||
|
const createProperty = useCreatePropertyMutation();
|
||||||
|
|
||||||
|
const groupableProperties = base.properties.filter(
|
||||||
|
(p) => p.type === "select" || p.type === "status",
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectData = groupableProperties.map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: p.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(value: string | null) => {
|
||||||
|
if (!value) return;
|
||||||
|
updateView.mutate({ viewId: view.id, pageId, config: { groupByPropertyId: value } });
|
||||||
|
},
|
||||||
|
[updateView, view.id, pageId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateStatus = useCallback(() => {
|
||||||
|
const todoId = uuid7();
|
||||||
|
const inProgressId = uuid7();
|
||||||
|
const completeId = uuid7();
|
||||||
|
createProperty.mutate(
|
||||||
|
{
|
||||||
|
pageId,
|
||||||
|
name: t("Status"),
|
||||||
|
type: "status",
|
||||||
|
typeOptions: {
|
||||||
|
choices: [
|
||||||
|
{ id: todoId, name: t("Not started"), color: "gray", category: "todo" },
|
||||||
|
{ id: inProgressId, name: t("In progress"), color: "blue", category: "inProgress" },
|
||||||
|
{ id: completeId, name: t("Done"), color: "green", category: "complete" },
|
||||||
|
],
|
||||||
|
choiceOrder: [todoId, inProgressId, completeId],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (newProperty) => {
|
||||||
|
updateView.mutate({
|
||||||
|
viewId: view.id,
|
||||||
|
pageId,
|
||||||
|
config: { groupByPropertyId: newProperty.id },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [createProperty, updateView, view.id, pageId, t]);
|
||||||
|
|
||||||
|
if (!editable) {
|
||||||
|
return (
|
||||||
|
<Stack align="center" justify="center" gap="md" style={{ flex: 1 }}>
|
||||||
|
<Text fw={500}>{t("This board has no grouping property yet.")}</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack align="center" justify="center" gap="md" style={{ flex: 1 }}>
|
||||||
|
<Text fw={500}>{t("Group this board by a select or status property.")}</Text>
|
||||||
|
{groupableProperties.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
placeholder={t("Choose a property")}
|
||||||
|
data={selectData}
|
||||||
|
value={view.config?.groupByPropertyId ?? null}
|
||||||
|
onChange={handleSelect}
|
||||||
|
w={240}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCreateStatus}
|
||||||
|
loading={createProperty.isPending}
|
||||||
|
>
|
||||||
|
{t("Create a status property")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { Popover, Select, Stack, Text, Switch, Group, UnstyledButton } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { IBase, IBaseView } from "@/ee/base/types/base.types";
|
||||||
|
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
|
||||||
|
import { useKanbanColumns } from "@/ee/base/hooks/use-kanban-columns";
|
||||||
|
import { choiceColor } from "@/ee/base/components/cells/choice-color";
|
||||||
|
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||||
|
|
||||||
|
type KanbanGroupByPickerProps = {
|
||||||
|
base: IBase;
|
||||||
|
view: IBaseView;
|
||||||
|
pageId: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function KanbanGroupByPicker({ base, view, pageId, children }: KanbanGroupByPickerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const updateView = useUpdateViewMutation();
|
||||||
|
const { allGroups, hasValidGroupBy } = useKanbanColumns(base, view);
|
||||||
|
|
||||||
|
const data = base.properties
|
||||||
|
.filter((p) => p.type === "select" || p.type === "status")
|
||||||
|
.map((p) => ({ value: p.id, label: p.name }));
|
||||||
|
|
||||||
|
const handleChange = (value: string | null) => {
|
||||||
|
updateView.mutate({
|
||||||
|
viewId: view.id,
|
||||||
|
pageId,
|
||||||
|
config: { groupByPropertyId: value ?? null },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleGroup = (key: string, currentlyHidden: boolean) => {
|
||||||
|
const current = view.config?.hiddenChoiceIds ?? [];
|
||||||
|
const next = currentlyHidden
|
||||||
|
? current.filter((k) => k !== key)
|
||||||
|
: [...current, key];
|
||||||
|
updateView.mutate({ viewId: view.id, pageId, config: { hiddenChoiceIds: next } });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
position="bottom-end"
|
||||||
|
shadow="md"
|
||||||
|
width={300}
|
||||||
|
withinPortal
|
||||||
|
trapFocus
|
||||||
|
closeOnEscape
|
||||||
|
closeOnClickOutside
|
||||||
|
>
|
||||||
|
<Popover.Target>{children}</Popover.Target>
|
||||||
|
<Popover.Dropdown p="xs">
|
||||||
|
<Stack gap={8}>
|
||||||
|
<Text size="xs" fw={600} c="dimmed">
|
||||||
|
{t("Group by")}
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
size="xs"
|
||||||
|
placeholder={t("Select a property")}
|
||||||
|
data={data}
|
||||||
|
value={view.config?.groupByPropertyId ?? null}
|
||||||
|
onChange={handleChange}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
{hasValidGroupBy && allGroups.length > 0 && (
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Text size="xs" fw={600} c="dimmed">
|
||||||
|
{t("Groups")}
|
||||||
|
</Text>
|
||||||
|
<Stack gap={0}>
|
||||||
|
{allGroups.map((g) => {
|
||||||
|
const dotColor = g.color
|
||||||
|
? (choiceColor(g.color).color as string)
|
||||||
|
: "light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3))";
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
key={g.key}
|
||||||
|
className={cellClasses.menuItem}
|
||||||
|
onClick={() => toggleGroup(g.key, g.hidden)}
|
||||||
|
>
|
||||||
|
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
flexShrink: 0,
|
||||||
|
background: dotColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
{g.isNoValue ? t("No value") : g.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Switch
|
||||||
|
size="xs"
|
||||||
|
checked={!g.hidden}
|
||||||
|
onChange={() => {}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
styles={{ track: { cursor: "pointer" } }}
|
||||||
|
/>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ import classes from "@/ee/base/styles/grid.module.css";
|
|||||||
type CreatePropertyPopoverProps = {
|
type CreatePropertyPopoverProps = {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
properties?: IBaseProperty[];
|
properties?: IBaseProperty[];
|
||||||
onPropertyCreated?: () => void;
|
onPropertyCreated?: (property: IBaseProperty) => void;
|
||||||
/** Custom trigger; must return a ref-forwarding element for Popover.Target.
|
/** Custom trigger; must return a ref-forwarding element for Popover.Target.
|
||||||
* Defaults to the grid's + column button. */
|
* Defaults to the grid's + column button. */
|
||||||
renderTarget?: (open: () => void) => React.ReactElement;
|
renderTarget?: (open: () => void) => React.ReactElement;
|
||||||
@@ -145,8 +145,8 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
|
|||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: (created) => {
|
||||||
onPropertyCreated?.();
|
onPropertyCreated?.(created);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -293,7 +293,7 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
|
|||||||
astVersion: 1,
|
astVersion: 1,
|
||||||
} as TypeOptions,
|
} as TypeOptions,
|
||||||
},
|
},
|
||||||
{ onSuccess: () => onPropertyCreated?.() },
|
{ onSuccess: (created) => onPropertyCreated?.(created) },
|
||||||
);
|
);
|
||||||
handleClose();
|
handleClose();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Popover } from "@mantine/core";
|
import { Popover } from "@mantine/core";
|
||||||
import { IconChevronDown } from "@tabler/icons-react";
|
import { IconChevronDown } from "@tabler/icons-react";
|
||||||
@@ -17,6 +17,8 @@ type PropertyRowProps = {
|
|||||||
onMenuOpenChange: (opened: boolean) => void;
|
onMenuOpenChange: (opened: boolean) => void;
|
||||||
onMenuDirtyChange: (dirty: boolean) => void;
|
onMenuDirtyChange: (dirty: boolean) => void;
|
||||||
onUpdate: (propertyId: string, value: unknown) => void;
|
onUpdate: (propertyId: string, value: unknown) => void;
|
||||||
|
autoFocusValue?: boolean;
|
||||||
|
onAutoFocused?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PropertyRow({
|
export function PropertyRow({
|
||||||
@@ -27,8 +29,23 @@ export function PropertyRow({
|
|||||||
onMenuOpenChange,
|
onMenuOpenChange,
|
||||||
onMenuDirtyChange,
|
onMenuDirtyChange,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
autoFocusValue,
|
||||||
|
onAutoFocused,
|
||||||
}: PropertyRowProps) {
|
}: PropertyRowProps) {
|
||||||
const canEdit = useBaseEditable();
|
const canEdit = useBaseEditable();
|
||||||
|
const rowRef = useRef<HTMLDivElement>(null);
|
||||||
|
const focusedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoFocusValue || focusedRef.current) return;
|
||||||
|
focusedRef.current = true;
|
||||||
|
const el = rowRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ block: "nearest" });
|
||||||
|
el.querySelector<HTMLElement>("input, textarea")?.focus();
|
||||||
|
}
|
||||||
|
onAutoFocused?.();
|
||||||
|
}, [autoFocusValue, onAutoFocused]);
|
||||||
|
|
||||||
const handleLabelClick = useCallback(() => {
|
const handleLabelClick = useCallback(() => {
|
||||||
onMenuOpenChange(!menuOpened);
|
onMenuOpenChange(!menuOpened);
|
||||||
@@ -48,7 +65,7 @@ export function PropertyRow({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.propertyRow}>
|
<div className={classes.propertyRow} ref={rowRef}>
|
||||||
{canEdit ? (
|
{canEdit ? (
|
||||||
<Popover
|
<Popover
|
||||||
opened={menuOpened}
|
opened={menuOpened}
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ export function RowDetailModal({
|
|||||||
// The shared closeRequest atom asks an open dirty PropertyMenuContent to
|
// The shared closeRequest atom asks an open dirty PropertyMenuContent to
|
||||||
// run its discard-confirm flow instead of being torn down mid-edit.
|
// run its discard-confirm flow instead of being torn down mid-edit.
|
||||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||||
|
const [newPropertyId, setNewPropertyId] = useState<string | null>(null);
|
||||||
|
const clearNewProperty = useCallback(() => setNewPropertyId(null), []);
|
||||||
const menuDirtyRef = useRef(false);
|
const menuDirtyRef = useRef(false);
|
||||||
const [closeRequest, setCloseRequest] = useAtom(
|
const [closeRequest, setCloseRequest] = useAtom(
|
||||||
propertyMenuCloseRequestAtomFamily(base.id),
|
propertyMenuCloseRequestAtomFamily(base.id),
|
||||||
@@ -310,6 +312,8 @@ export function RowDetailModal({
|
|||||||
property={property}
|
property={property}
|
||||||
row={row}
|
row={row}
|
||||||
pageId={base.id}
|
pageId={base.id}
|
||||||
|
autoFocusValue={property.id === newPropertyId}
|
||||||
|
onAutoFocused={clearNewProperty}
|
||||||
menuOpened={openMenuId === property.id}
|
menuOpened={openMenuId === property.id}
|
||||||
onMenuOpenChange={(nextOpened) =>
|
onMenuOpenChange={(nextOpened) =>
|
||||||
handleMenuOpenChange(property.id, nextOpened)
|
handleMenuOpenChange(property.id, nextOpened)
|
||||||
@@ -329,6 +333,7 @@ export function RowDetailModal({
|
|||||||
<CreatePropertyPopover
|
<CreatePropertyPopover
|
||||||
pageId={base.id}
|
pageId={base.id}
|
||||||
properties={base.properties}
|
properties={base.properties}
|
||||||
|
onPropertyCreated={(p) => setNewPropertyId(p.id)}
|
||||||
renderTarget={(open) => (
|
renderTarget={(open) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
|
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
|
||||||
import { timeAgo } from "@/lib/time.ts";
|
import { timeAgo } from "@/lib/time.ts";
|
||||||
@@ -22,18 +22,27 @@ export function RowDetailTitle({
|
|||||||
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
|
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
|
||||||
: "";
|
: "";
|
||||||
const [value, setValue] = useState(initial);
|
const [value, setValue] = useState(initial);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const didAutofocusRef = useRef(false);
|
||||||
|
|
||||||
// Re-sync when the row changes underneath us (navigation or remote edit).
|
// Re-sync when the row changes underneath us (navigation or remote edit).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValue(initial);
|
setValue(initial);
|
||||||
}, [initial]);
|
}, [initial]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (didAutofocusRef.current || !canEdit || initial) return;
|
||||||
|
didAutofocusRef.current = true;
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, [canEdit, initial]);
|
||||||
|
|
||||||
const updatedAgo = row.updatedAt ? timeAgo(new Date(row.updatedAt)) : "";
|
const updatedAgo = row.updatedAt ? timeAgo(new Date(row.updatedAt)) : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={classes.header}>
|
<header className={classes.header}>
|
||||||
{canEdit ? (
|
{canEdit ? (
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
className={classes.titleInput}
|
className={classes.titleInput}
|
||||||
placeholder={t("Untitled")}
|
placeholder={t("Untitled")}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { Menu, ActionIcon, Tooltip } from "@mantine/core";
|
||||||
|
import { IconPlus, IconTable, IconLayoutKanban, IconArrowLeft } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { IBase } from "@/ee/base/types/base.types";
|
||||||
|
import { useCreateViewMutation } from "@/ee/base/queries/base-view-query";
|
||||||
|
import { activeViewIdAtomFamily } from "@/ee/base/atoms/base-atoms";
|
||||||
|
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
|
||||||
|
|
||||||
|
type Panel = "types" | "groupBy";
|
||||||
|
|
||||||
|
type ViewCreateMenuProps = {
|
||||||
|
base: IBase;
|
||||||
|
pageId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ViewCreateMenu({ base, pageId }: ViewCreateMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [opened, setOpened] = useState(false);
|
||||||
|
const [panel, setPanel] = useState<Panel>("types");
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const createViewMutation = useCreateViewMutation();
|
||||||
|
const [, setActiveViewId] = useAtom(
|
||||||
|
activeViewIdAtomFamily(pageId),
|
||||||
|
) as unknown as [string | null, (val: string | null) => void];
|
||||||
|
|
||||||
|
const groupable = base.properties.filter(
|
||||||
|
(p) => p.type === "select" || p.type === "status",
|
||||||
|
);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setOpened(false);
|
||||||
|
setPanel("types");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submitView = useCallback(
|
||||||
|
(input: { name: string; type: "table" | "kanban"; config?: Record<string, unknown> }) => {
|
||||||
|
createViewMutation.mutate(
|
||||||
|
{ pageId, ...input },
|
||||||
|
{ onSuccess: (created) => setActiveViewId(created.id) },
|
||||||
|
);
|
||||||
|
close();
|
||||||
|
},
|
||||||
|
[pageId, createViewMutation, setActiveViewId, close],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateTable = useCallback(() => {
|
||||||
|
submitView({ name: t("Table"), type: "table" });
|
||||||
|
}, [submitView, t]);
|
||||||
|
|
||||||
|
const handleBoardClick = useCallback(() => {
|
||||||
|
if (groupable.length <= 1) {
|
||||||
|
const config =
|
||||||
|
groupable.length === 1
|
||||||
|
? { groupByPropertyId: groupable[0].id }
|
||||||
|
: undefined;
|
||||||
|
submitView({ name: t("Kanban"), type: "kanban", config });
|
||||||
|
} else {
|
||||||
|
setPanel("groupBy");
|
||||||
|
}
|
||||||
|
}, [groupable, submitView, t]);
|
||||||
|
|
||||||
|
const handleGroupByPick = useCallback(
|
||||||
|
(propertyId: string) => {
|
||||||
|
submitView({
|
||||||
|
name: t("Kanban"),
|
||||||
|
type: "kanban",
|
||||||
|
config: { groupByPropertyId: propertyId },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[submitView, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const raf = requestAnimationFrame(() => {
|
||||||
|
dropdownRef.current
|
||||||
|
?.querySelector<HTMLElement>("[data-menu-item]:not([data-disabled])")
|
||||||
|
?.focus();
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [panel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
opened={opened}
|
||||||
|
onChange={(o) => {
|
||||||
|
setOpened(o);
|
||||||
|
if (!o) setPanel("types");
|
||||||
|
}}
|
||||||
|
position="bottom-start"
|
||||||
|
shadow="md"
|
||||||
|
width={200}
|
||||||
|
withinPortal
|
||||||
|
closeOnItemClick={false}
|
||||||
|
>
|
||||||
|
<Menu.Target>
|
||||||
|
<Tooltip label={t("Add view")}>
|
||||||
|
<ActionIcon variant="subtle" size="sm" color="gray" aria-label={t("Add view")}>
|
||||||
|
<IconPlus size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Menu.Target>
|
||||||
|
|
||||||
|
<Menu.Dropdown ref={dropdownRef}>
|
||||||
|
{panel === "types" && (
|
||||||
|
<>
|
||||||
|
<Menu.Item leftSection={<IconTable size={14} />} onClick={handleCreateTable}>
|
||||||
|
{t("Table")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item leftSection={<IconLayoutKanban size={14} />} onClick={handleBoardClick}>
|
||||||
|
{t("Kanban")}
|
||||||
|
</Menu.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{panel === "groupBy" && (
|
||||||
|
<>
|
||||||
|
<Menu.Item leftSection={<IconArrowLeft size={14} />} onClick={() => setPanel("types")}>
|
||||||
|
{t("Group by")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
|
{groupable.map((p) => {
|
||||||
|
const Icon = getDescriptor(p.type)?.icon;
|
||||||
|
return (
|
||||||
|
<Menu.Item
|
||||||
|
key={p.id}
|
||||||
|
leftSection={Icon ? <Icon size={14} /> : undefined}
|
||||||
|
onClick={() => handleGroupByPick(p.id)}
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</Menu.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@ import {
|
|||||||
IBase,
|
IBase,
|
||||||
IBaseRow,
|
IBaseRow,
|
||||||
IBaseView,
|
IBaseView,
|
||||||
|
FilterGroup,
|
||||||
} from "@/ee/base/types/base.types";
|
} from "@/ee/base/types/base.types";
|
||||||
import { BaseTable } from "@/ee/base/components/base-table";
|
import { BaseTable } from "@/ee/base/components/base-table";
|
||||||
|
import { BaseKanban } from "@/ee/base/components/kanban/base-kanban";
|
||||||
|
|
||||||
type ViewRendererProps = {
|
type ViewRendererProps = {
|
||||||
base: IBase;
|
base: IBase;
|
||||||
@@ -13,6 +15,7 @@ type ViewRendererProps = {
|
|||||||
table: Table<IBaseRow>;
|
table: Table<IBaseRow>;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
|
editable: boolean;
|
||||||
isFiltered: boolean;
|
isFiltered: boolean;
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
isFetchingNextPage: boolean;
|
isFetchingNextPage: boolean;
|
||||||
@@ -29,15 +32,28 @@ type ViewRendererProps = {
|
|||||||
persistViewConfig: () => void;
|
persistViewConfig: () => void;
|
||||||
scrollportRef: React.RefObject<HTMLDivElement>;
|
scrollportRef: React.RefObject<HTMLDivElement>;
|
||||||
aboveBand?: React.ReactNode;
|
aboveBand?: React.ReactNode;
|
||||||
|
kanbanFilter?: FilterGroup | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ViewRenderer(props: ViewRendererProps) {
|
export function ViewRenderer(props: ViewRendererProps) {
|
||||||
const viewType = props.effectiveView?.type ?? "table";
|
const viewType = props.effectiveView?.type ?? "table";
|
||||||
|
|
||||||
|
if (viewType === "kanban") {
|
||||||
|
return (
|
||||||
|
<BaseKanban
|
||||||
|
base={props.base}
|
||||||
|
view={props.effectiveView!}
|
||||||
|
pageId={props.pageId}
|
||||||
|
embedded={props.embedded}
|
||||||
|
editable={props.editable}
|
||||||
|
viewFilter={props.kanbanFilter}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (viewType === "table") {
|
if (viewType === "table") {
|
||||||
return <BaseTable {...props} />;
|
return <BaseTable {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kanban not yet implemented; fall back to table to avoid blank page.
|
|
||||||
return <BaseTable {...props} />;
|
return <BaseTable {...props} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,19 +10,17 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
Text,
|
Text,
|
||||||
ActionIcon,
|
|
||||||
Tooltip,
|
|
||||||
TextInput,
|
TextInput,
|
||||||
Popover,
|
Popover,
|
||||||
Stack,
|
Stack,
|
||||||
Divider,
|
Divider,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconPlus,
|
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconTable,
|
IconTable,
|
||||||
IconLink,
|
IconLink,
|
||||||
|
IconLayoutKanban,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||||
@@ -36,7 +34,8 @@ import {
|
|||||||
type Edge,
|
type Edge,
|
||||||
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
||||||
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||||
import { IBaseView } from "@/ee/base/types/base.types";
|
import { IBase, IBaseView } from "@/ee/base/types/base.types";
|
||||||
|
import { ViewCreateMenu } from "@/ee/base/components/views/view-create-menu";
|
||||||
import {
|
import {
|
||||||
useUpdateViewMutation,
|
useUpdateViewMutation,
|
||||||
useDeleteViewMutation,
|
useDeleteViewMutation,
|
||||||
@@ -54,6 +53,8 @@ type ViewTabsProps = {
|
|||||||
pageId: string;
|
pageId: string;
|
||||||
onViewChange: (viewId: string) => void;
|
onViewChange: (viewId: string) => void;
|
||||||
onAddView?: () => void;
|
onAddView?: () => void;
|
||||||
|
base?: IBase;
|
||||||
|
canAddView?: boolean;
|
||||||
/** Standalone base-page link for a view, used by "Copy link to view". */
|
/** Standalone base-page link for a view, used by "Copy link to view". */
|
||||||
getViewShareUrl?: (viewId: string) => string | null;
|
getViewShareUrl?: (viewId: string) => string | null;
|
||||||
};
|
};
|
||||||
@@ -64,6 +65,8 @@ export function ViewTabs({
|
|||||||
pageId,
|
pageId,
|
||||||
onViewChange,
|
onViewChange,
|
||||||
onAddView,
|
onAddView,
|
||||||
|
base,
|
||||||
|
canAddView,
|
||||||
getViewShareUrl,
|
getViewShareUrl,
|
||||||
}: ViewTabsProps) {
|
}: ViewTabsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -174,7 +177,6 @@ export function ViewTabs({
|
|||||||
isEditing={view.id === editingViewId}
|
isEditing={view.id === editingViewId}
|
||||||
editingName={editingName}
|
editingName={editingName}
|
||||||
canDelete={orderedViews.length > 1}
|
canDelete={orderedViews.length > 1}
|
||||||
multipleViews={orderedViews.length > 1}
|
|
||||||
reorderEnabled={editable && orderedViews.length > 1}
|
reorderEnabled={editable && orderedViews.length > 1}
|
||||||
onReorder={handleReorder}
|
onReorder={handleReorder}
|
||||||
onClick={() => onViewChange(view.id)}
|
onClick={() => onViewChange(view.id)}
|
||||||
@@ -186,17 +188,8 @@ export function ViewTabs({
|
|||||||
getViewShareUrl={getViewShareUrl}
|
getViewShareUrl={getViewShareUrl}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{onAddView && (
|
{canAddView && base && (
|
||||||
<Tooltip label={t("Add view")}>
|
<ViewCreateMenu base={base} pageId={pageId} />
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
size="sm"
|
|
||||||
color="gray"
|
|
||||||
onClick={onAddView}
|
|
||||||
>
|
|
||||||
<IconPlus size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
@@ -208,7 +201,6 @@ function ViewTab({
|
|||||||
isEditing,
|
isEditing,
|
||||||
editingName,
|
editingName,
|
||||||
canDelete,
|
canDelete,
|
||||||
multipleViews,
|
|
||||||
reorderEnabled,
|
reorderEnabled,
|
||||||
onReorder,
|
onReorder,
|
||||||
onClick,
|
onClick,
|
||||||
@@ -224,7 +216,6 @@ function ViewTab({
|
|||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
editingName: string;
|
editingName: string;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
multipleViews: boolean;
|
|
||||||
reorderEnabled: boolean;
|
reorderEnabled: boolean;
|
||||||
onReorder: (sourceId: string, targetId: string, edge: Edge) => void;
|
onReorder: (sourceId: string, targetId: string, edge: Edge) => void;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@@ -336,14 +327,17 @@ function ViewTab({
|
|||||||
padding: "2px 10px",
|
padding: "2px 10px",
|
||||||
borderRadius: "var(--mantine-radius-xl)",
|
borderRadius: "var(--mantine-radius-xl)",
|
||||||
fontWeight: isActive ? 600 : 400,
|
fontWeight: isActive ? 600 : 400,
|
||||||
backgroundColor:
|
backgroundColor: isActive
|
||||||
isActive && multipleViews
|
? "light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5))"
|
||||||
? "light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5))"
|
: undefined,
|
||||||
: undefined,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
<IconTable size={14} opacity={isActive ? 1 : 0.5} />
|
{view.type === "kanban" ? (
|
||||||
|
<IconLayoutKanban size={14} opacity={isActive ? 1 : 0.5} />
|
||||||
|
) : (
|
||||||
|
<IconTable size={14} opacity={isActive ? 1 : 0.5} />
|
||||||
|
)}
|
||||||
<Text size="sm" lh={1.2} c={isActive ? undefined : "dimmed"}>
|
<Text size="sm" lh={1.2} c={isActive ? undefined : "dimmed"}>
|
||||||
{view.name}
|
{view.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { formatNumber } from "@/ee/base/components/cells/cell-number";
|
||||||
|
import { formatDateDisplay } from "@/ee/base/components/cells/cell-date";
|
||||||
|
|
||||||
|
export { formatNumber, formatDateDisplay };
|
||||||
|
|
||||||
|
export function formatTimestamp(value: string | null | undefined): string {
|
||||||
|
if (typeof value !== "string" || !value) return "";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (isNaN(date.getTime())) return "";
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLongTextPreview(value: string | null | undefined): string {
|
||||||
|
if (typeof value !== "string") return "";
|
||||||
|
return value.replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { selectedRowIdsAtomFamily } from "@/ee/base/atoms/base-atoms";
|
import { selectedRowIdsAtomFamily } from "@/ee/base/atoms/base-atoms";
|
||||||
import { formulaRecomputeAtom } from "@/ee/base/atoms/formula-recompute-atom";
|
import { formulaRecomputeAtom } from "@/ee/base/atoms/formula-recompute-atom";
|
||||||
import { IPagination } from "@/lib/types";
|
import { IPagination } from "@/lib/types";
|
||||||
|
import { invalidateKanbanColumns } from "@/ee/base/queries/base-row-query";
|
||||||
|
|
||||||
type BaseRowCreated = {
|
type BaseRowCreated = {
|
||||||
operation: "base:row:created";
|
operation: "base:row:created";
|
||||||
@@ -161,45 +162,57 @@ export function useBaseSocket(pageId: string | undefined): void {
|
|||||||
switch (event.operation) {
|
switch (event.operation) {
|
||||||
case "base:row:created": {
|
case "base:row:created": {
|
||||||
const e = event as BaseRowCreated;
|
const e = event as BaseRowCreated;
|
||||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
const baseForCreate = queryClient.getQueryData<IBase>(["bases", pageId]);
|
||||||
{ queryKey: ["base-rows", pageId] },
|
const hasKanbanForCreate = (baseForCreate?.views ?? []).some((v) => v.type === "kanban");
|
||||||
(old) => {
|
if (hasKanbanForCreate) {
|
||||||
if (!old) return old;
|
invalidateKanbanColumns(pageId);
|
||||||
const lastPageIndex = old.pages.length - 1;
|
} else {
|
||||||
return {
|
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||||
...old,
|
{ queryKey: ["base-rows", pageId] },
|
||||||
pages: old.pages.map((page, index) =>
|
(old) => {
|
||||||
index === lastPageIndex
|
if (!old) return old;
|
||||||
? { ...page, items: [...page.items, e.row] }
|
const lastPageIndex = old.pages.length - 1;
|
||||||
: page,
|
return {
|
||||||
),
|
...old,
|
||||||
};
|
pages: old.pages.map((page, index) =>
|
||||||
},
|
index === lastPageIndex
|
||||||
);
|
? { ...page, items: [...page.items, e.row] }
|
||||||
|
: page,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "base:row:updated": {
|
case "base:row:updated": {
|
||||||
const e = event as BaseRowUpdated;
|
const e = event as BaseRowUpdated;
|
||||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
const baseForUpdate = queryClient.getQueryData<IBase>(["bases", pageId]);
|
||||||
{ queryKey: ["base-rows", pageId] },
|
const hasKanbanForUpdate = (baseForUpdate?.views ?? []).some((v) => v.type === "kanban");
|
||||||
(old) =>
|
if (hasKanbanForUpdate) {
|
||||||
!old
|
invalidateKanbanColumns(pageId);
|
||||||
? old
|
} else {
|
||||||
: {
|
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||||
...old,
|
{ queryKey: ["base-rows", pageId] },
|
||||||
pages: old.pages.map((page) => ({
|
(old) =>
|
||||||
...page,
|
!old
|
||||||
items: page.items.map((row) =>
|
? old
|
||||||
row.id === e.rowId
|
: {
|
||||||
? {
|
...old,
|
||||||
...row,
|
pages: old.pages.map((page) => ({
|
||||||
cells: { ...row.cells, ...e.updatedCells },
|
...page,
|
||||||
}
|
items: page.items.map((row) =>
|
||||||
: row,
|
row.id === e.rowId
|
||||||
),
|
? {
|
||||||
})),
|
...row,
|
||||||
},
|
cells: { ...row.cells, ...e.updatedCells },
|
||||||
);
|
}
|
||||||
|
: row,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "base:row:deleted": {
|
case "base:row:deleted": {
|
||||||
@@ -258,23 +271,29 @@ export function useBaseSocket(pageId: string | undefined): void {
|
|||||||
}
|
}
|
||||||
case "base:row:reordered": {
|
case "base:row:reordered": {
|
||||||
const e = event as BaseRowReordered;
|
const e = event as BaseRowReordered;
|
||||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
const baseForReorder = queryClient.getQueryData<IBase>(["bases", pageId]);
|
||||||
{ queryKey: ["base-rows", pageId] },
|
const hasKanbanForReorder = (baseForReorder?.views ?? []).some((v) => v.type === "kanban");
|
||||||
(old) =>
|
if (hasKanbanForReorder) {
|
||||||
!old
|
invalidateKanbanColumns(pageId);
|
||||||
? old
|
} else {
|
||||||
: {
|
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||||
...old,
|
{ queryKey: ["base-rows", pageId] },
|
||||||
pages: old.pages.map((page) => ({
|
(old) =>
|
||||||
...page,
|
!old
|
||||||
items: page.items.map((row) =>
|
? old
|
||||||
row.id === e.rowId
|
: {
|
||||||
? { ...row, position: e.position }
|
...old,
|
||||||
: row,
|
pages: old.pages.map((page) => ({
|
||||||
),
|
...page,
|
||||||
})),
|
items: page.items.map((row) =>
|
||||||
},
|
row.id === e.rowId
|
||||||
);
|
? { ...row, position: e.position }
|
||||||
|
: row,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "base:rows:updated": {
|
case "base:rows:updated": {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { type RefObject, useEffect } from "react";
|
||||||
|
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||||
|
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||||
|
import { unsafeOverflowAutoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/unsafe-overflow/element";
|
||||||
|
import { KANBAN_CARD_DRAG_TYPE, KANBAN_COLUMN_DRAG_TYPE } from "@/ee/base/types/base.types";
|
||||||
|
|
||||||
|
const HEADER_BAND_REACH_PX = 60;
|
||||||
|
const EDGE_OUTWARD_REACH_PX = 80;
|
||||||
|
|
||||||
|
export function useKanbanBoardAutoScroll<T extends HTMLElement>(
|
||||||
|
boardRef: RefObject<T | null>,
|
||||||
|
pageId: string,
|
||||||
|
): void {
|
||||||
|
useEffect(() => {
|
||||||
|
const element = boardRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const canScroll = ({ source }: { source: { data: Record<string, unknown> } }) =>
|
||||||
|
(source.data?.type === KANBAN_CARD_DRAG_TYPE ||
|
||||||
|
source.data?.type === KANBAN_COLUMN_DRAG_TYPE) &&
|
||||||
|
source.data?.pageId === pageId;
|
||||||
|
|
||||||
|
return combine(
|
||||||
|
autoScrollForElements({
|
||||||
|
element,
|
||||||
|
canScroll,
|
||||||
|
getAllowedAxis: () => "horizontal" as const,
|
||||||
|
}),
|
||||||
|
unsafeOverflowAutoScrollForElements({
|
||||||
|
element,
|
||||||
|
canScroll,
|
||||||
|
getAllowedAxis: () => "horizontal" as const,
|
||||||
|
getOverflow: () => ({
|
||||||
|
forLeftEdge: { left: EDGE_OUTWARD_REACH_PX, top: HEADER_BAND_REACH_PX },
|
||||||
|
forRightEdge: { right: EDGE_OUTWARD_REACH_PX, top: HEADER_BAND_REACH_PX },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [boardRef, pageId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKanbanColumnAutoScroll<T extends HTMLElement>(
|
||||||
|
listRef: RefObject<T | null>,
|
||||||
|
pageId: string,
|
||||||
|
): void {
|
||||||
|
useEffect(() => {
|
||||||
|
const element = listRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const canScroll = ({ source }: { source: { data: Record<string, unknown> } }) =>
|
||||||
|
source.data?.type === KANBAN_CARD_DRAG_TYPE && source.data?.pageId === pageId;
|
||||||
|
|
||||||
|
return combine(
|
||||||
|
autoScrollForElements({
|
||||||
|
element,
|
||||||
|
canScroll,
|
||||||
|
getAllowedAxis: () => "vertical" as const,
|
||||||
|
}),
|
||||||
|
unsafeOverflowAutoScrollForElements({
|
||||||
|
element,
|
||||||
|
canScroll,
|
||||||
|
getAllowedAxis: () => "vertical" as const,
|
||||||
|
getOverflow: () => ({
|
||||||
|
forTopEdge: { top: EDGE_OUTWARD_REACH_PX },
|
||||||
|
forBottomEdge: { bottom: EDGE_OUTWARD_REACH_PX },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [listRef, pageId]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { type RefObject, useEffect, useState } from "react";
|
||||||
|
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||||
|
import {
|
||||||
|
draggable,
|
||||||
|
dropTargetForElements,
|
||||||
|
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||||
|
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
|
||||||
|
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
|
||||||
|
import {
|
||||||
|
attachClosestEdge,
|
||||||
|
extractClosestEdge,
|
||||||
|
type Edge,
|
||||||
|
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
||||||
|
import { KANBAN_CARD_DRAG_TYPE } from "@/ee/base/types/base.types";
|
||||||
|
import classes from "@/ee/base/styles/kanban.module.css";
|
||||||
|
|
||||||
|
export function useKanbanCardDnd({
|
||||||
|
cardRef,
|
||||||
|
rowId,
|
||||||
|
columnKey,
|
||||||
|
pageId,
|
||||||
|
}: {
|
||||||
|
cardRef: RefObject<HTMLDivElement | null>;
|
||||||
|
rowId: string;
|
||||||
|
columnKey: string;
|
||||||
|
pageId: string;
|
||||||
|
}): { closestEdge: Edge | null; isDragging: boolean } {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cardEl = cardRef.current;
|
||||||
|
if (!cardEl) return;
|
||||||
|
return combine(
|
||||||
|
draggable({
|
||||||
|
element: cardEl,
|
||||||
|
getInitialData: () => ({
|
||||||
|
type: KANBAN_CARD_DRAG_TYPE,
|
||||||
|
rowId,
|
||||||
|
columnKey,
|
||||||
|
pageId,
|
||||||
|
}),
|
||||||
|
onGenerateDragPreview: ({ nativeSetDragImage }) => {
|
||||||
|
const width = cardEl.getBoundingClientRect().width;
|
||||||
|
setCustomNativeDragPreview({
|
||||||
|
nativeSetDragImage,
|
||||||
|
getOffset: pointerOutsideOfPreview({ x: "12px", y: "8px" }),
|
||||||
|
render: ({ container }) => {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = classes.cardDragPreview;
|
||||||
|
card.style.width = `${width}px`;
|
||||||
|
const clone = cardEl.cloneNode(true) as HTMLElement;
|
||||||
|
clone.style.opacity = "1";
|
||||||
|
card.appendChild(clone);
|
||||||
|
container.appendChild(card);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onDragStart: () => setIsDragging(true),
|
||||||
|
onDrop: () => setIsDragging(false),
|
||||||
|
}),
|
||||||
|
dropTargetForElements({
|
||||||
|
element: cardEl,
|
||||||
|
canDrop: ({ source }) =>
|
||||||
|
source.data.type === KANBAN_CARD_DRAG_TYPE &&
|
||||||
|
source.data.pageId === pageId,
|
||||||
|
getData: ({ input, element }) =>
|
||||||
|
attachClosestEdge(
|
||||||
|
{ rowId, columnKey },
|
||||||
|
{ input, element, allowedEdges: ["top", "bottom"] },
|
||||||
|
),
|
||||||
|
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
|
||||||
|
onDragLeave: () => setClosestEdge(null),
|
||||||
|
onDrop: () => setClosestEdge(null),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [cardRef, rowId, columnKey, pageId]);
|
||||||
|
|
||||||
|
return { closestEdge, isDragging };
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||||
|
import { NO_VALUE_CHOICE_ID, type IBaseRow } from "@/ee/base/types/base.types";
|
||||||
|
|
||||||
|
export function resolveCardDrop(args: {
|
||||||
|
draggedRowId: string;
|
||||||
|
targetRowId: string | null;
|
||||||
|
edge: "top" | "bottom" | null;
|
||||||
|
targetColumnKey: string;
|
||||||
|
sourceColumnKey: string;
|
||||||
|
targetColumnRows: IBaseRow[];
|
||||||
|
}): { columnChanged: boolean; destChoiceValue: string | null; position: string } | null {
|
||||||
|
const { draggedRowId, targetRowId, edge, targetColumnKey, sourceColumnKey, targetColumnRows } = args;
|
||||||
|
const columnChanged = sourceColumnKey !== targetColumnKey;
|
||||||
|
if (!columnChanged && draggedRowId === targetRowId) return null;
|
||||||
|
const destChoiceValue = targetColumnKey === NO_VALUE_CHOICE_ID ? null : targetColumnKey;
|
||||||
|
const rows = targetColumnRows.filter((r) => r.id !== draggedRowId);
|
||||||
|
let position: string;
|
||||||
|
if (!targetRowId || edge === null) {
|
||||||
|
const last = rows[rows.length - 1];
|
||||||
|
position = generateJitteredKeyBetween(last?.position ?? null, null);
|
||||||
|
} else {
|
||||||
|
const idx = rows.findIndex((r) => r.id === targetRowId);
|
||||||
|
if (idx === -1) {
|
||||||
|
const last = rows[rows.length - 1];
|
||||||
|
position = generateJitteredKeyBetween(last?.position ?? null, null);
|
||||||
|
} else {
|
||||||
|
const neighbor = edge === "top" ? idx - 1 : idx + 1;
|
||||||
|
const lower = edge === "top" ? rows[neighbor]?.position ?? null : rows[idx].position;
|
||||||
|
const upper = edge === "top" ? rows[idx].position : rows[neighbor]?.position ?? null;
|
||||||
|
position = generateJitteredKeyBetween(lower, upper);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { columnChanged, destChoiceValue, position };
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { type RefObject, useEffect, useState } from "react";
|
||||||
|
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||||
|
import {
|
||||||
|
draggable,
|
||||||
|
dropTargetForElements,
|
||||||
|
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||||
|
import {
|
||||||
|
attachClosestEdge,
|
||||||
|
extractClosestEdge,
|
||||||
|
type Edge,
|
||||||
|
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
||||||
|
import { KANBAN_COLUMN_DRAG_TYPE } from "@/ee/base/types/base.types";
|
||||||
|
|
||||||
|
export function useKanbanColumnDnd({
|
||||||
|
headerRef,
|
||||||
|
handleRef,
|
||||||
|
columnKey,
|
||||||
|
pageId,
|
||||||
|
}: {
|
||||||
|
headerRef: RefObject<HTMLDivElement | null>;
|
||||||
|
handleRef: RefObject<HTMLDivElement | null>;
|
||||||
|
columnKey: string;
|
||||||
|
pageId: string;
|
||||||
|
}): { closestEdge: Edge | null; isDragging: boolean } {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const headerEl = headerRef.current;
|
||||||
|
const handleEl = handleRef.current;
|
||||||
|
if (!headerEl || !handleEl) return;
|
||||||
|
return combine(
|
||||||
|
draggable({
|
||||||
|
element: headerEl,
|
||||||
|
dragHandle: handleEl,
|
||||||
|
getInitialData: () => ({
|
||||||
|
type: KANBAN_COLUMN_DRAG_TYPE,
|
||||||
|
columnKey,
|
||||||
|
pageId,
|
||||||
|
}),
|
||||||
|
onDragStart: () => setIsDragging(true),
|
||||||
|
onDrop: () => setIsDragging(false),
|
||||||
|
}),
|
||||||
|
dropTargetForElements({
|
||||||
|
element: headerEl,
|
||||||
|
canDrop: ({ source }) =>
|
||||||
|
source.data.type === KANBAN_COLUMN_DRAG_TYPE &&
|
||||||
|
source.data.pageId === pageId &&
|
||||||
|
source.data.columnKey !== columnKey,
|
||||||
|
getData: ({ input, element }) =>
|
||||||
|
attachClosestEdge(
|
||||||
|
{ columnKey },
|
||||||
|
{ input, element, allowedEdges: ["left", "right"] },
|
||||||
|
),
|
||||||
|
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
|
||||||
|
onDragLeave: () => setClosestEdge(null),
|
||||||
|
onDrop: () => setClosestEdge(null),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [headerRef, handleRef, columnKey, pageId]);
|
||||||
|
|
||||||
|
return { closestEdge, isDragging };
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { IBase, IBaseView, KanbanColumn, NO_VALUE_CHOICE_ID, SelectTypeOptions } from "@/ee/base/types/base.types";
|
||||||
|
|
||||||
|
export type KanbanGroup = KanbanColumn & { hidden: boolean };
|
||||||
|
|
||||||
|
export function useKanbanColumns(
|
||||||
|
base: IBase | undefined,
|
||||||
|
view: IBaseView | undefined,
|
||||||
|
): {
|
||||||
|
groupByPropertyId: string | undefined;
|
||||||
|
columns: KanbanColumn[];
|
||||||
|
allGroups: KanbanGroup[];
|
||||||
|
hasValidGroupBy: boolean;
|
||||||
|
} {
|
||||||
|
return useMemo(() => {
|
||||||
|
const groupByPropertyId = view?.config?.groupByPropertyId;
|
||||||
|
const prop = groupByPropertyId ? base?.properties.find((p) => p.id === groupByPropertyId) : undefined;
|
||||||
|
const groupable = prop && (prop.type === "select" || prop.type === "status");
|
||||||
|
|
||||||
|
if (!groupable || !prop || !view) {
|
||||||
|
return { groupByPropertyId, columns: [], allGroups: [], hasValidGroupBy: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeOptions = prop.typeOptions as SelectTypeOptions;
|
||||||
|
const choices = typeOptions?.choices ?? [];
|
||||||
|
const choiceMap = new Map(choices.map((c) => [c.id, c]));
|
||||||
|
const validKeys = new Set([NO_VALUE_CHOICE_ID, ...choices.map((c) => c.id)]);
|
||||||
|
|
||||||
|
const config = view.config;
|
||||||
|
const configChoiceOrder: string[] = config.choiceOrder?.length
|
||||||
|
? config.choiceOrder.filter((k) => validKeys.has(k))
|
||||||
|
: [...(typeOptions?.choiceOrder ?? choices.map((c) => c.id)), NO_VALUE_CHOICE_ID];
|
||||||
|
|
||||||
|
const inOrder = new Set(configChoiceOrder);
|
||||||
|
const baseOrder = [
|
||||||
|
...configChoiceOrder,
|
||||||
|
...choices.map((c) => c.id).filter((id) => !inOrder.has(id)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const hidden = new Set(config.hiddenChoiceIds ?? []);
|
||||||
|
const allGroups: KanbanGroup[] = baseOrder.map((k) => {
|
||||||
|
if (k === NO_VALUE_CHOICE_ID) {
|
||||||
|
return { key: k, name: "No value", color: undefined, isNoValue: true, hidden: hidden.has(k) };
|
||||||
|
}
|
||||||
|
const choice = choiceMap.get(k);
|
||||||
|
return { key: k, name: choice?.name ?? k, color: choice?.color, isNoValue: false, hidden: hidden.has(k) };
|
||||||
|
});
|
||||||
|
const columns: KanbanColumn[] = allGroups.filter((g) => !g.hidden);
|
||||||
|
|
||||||
|
return { groupByPropertyId, columns, allGroups, hasValidGroupBy: true };
|
||||||
|
}, [base, view]);
|
||||||
|
}
|
||||||
@@ -58,8 +58,8 @@ export function useConvertPageToBaseMutation() {
|
|||||||
const [, setTreeData] = useAtom(treeDataAtom);
|
const [, setTreeData] = useAtom(treeDataAtom);
|
||||||
const [socket] = useAtom(socketAtom);
|
const [socket] = useAtom(socketAtom);
|
||||||
|
|
||||||
return useMutation<IBase, Error, { pageId: string }>({
|
return useMutation<IBase, Error, { pageId: string; template?: "kanban" }>({
|
||||||
mutationFn: ({ pageId }) => convertPageToBase(pageId),
|
mutationFn: ({ pageId, template }) => convertPageToBase(pageId, template),
|
||||||
onSuccess: (base) => {
|
onSuccess: (base) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["pages"] });
|
queryClient.invalidateQueries({ queryKey: ["pages"] });
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import {
|
|||||||
listRows,
|
listRows,
|
||||||
reorderRow,
|
reorderRow,
|
||||||
countRows,
|
countRows,
|
||||||
|
groupCountRows,
|
||||||
IBaseRowsPage,
|
IBaseRowsPage,
|
||||||
} from "@/ee/base/services/base-service";
|
} from "@/ee/base/services/base-service";
|
||||||
import {
|
import {
|
||||||
|
IBase,
|
||||||
IBaseRow,
|
IBaseRow,
|
||||||
CreateRowInput,
|
CreateRowInput,
|
||||||
UpdateRowInput,
|
UpdateRowInput,
|
||||||
@@ -27,7 +29,9 @@ import {
|
|||||||
SearchSpec,
|
SearchSpec,
|
||||||
ViewSortConfig,
|
ViewSortConfig,
|
||||||
CountRowsResult,
|
CountRowsResult,
|
||||||
|
GroupCountsResult,
|
||||||
RowReferences,
|
RowReferences,
|
||||||
|
NO_VALUE_CHOICE_ID,
|
||||||
} from "@/ee/base/types/base.types";
|
} from "@/ee/base/types/base.types";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { queryClient } from "@/main";
|
import { queryClient } from "@/main";
|
||||||
@@ -42,7 +46,7 @@ type RowCacheContext = {
|
|||||||
|
|
||||||
// An empty group filter is the draft-layer's "no predicates" marker (see use-view-draft.ts).
|
// An empty group filter is the draft-layer's "no predicates" marker (see use-view-draft.ts).
|
||||||
// Strip it at the query boundary to keep request payloads clean and cache keys stable.
|
// Strip it at the query boundary to keep request payloads clean and cache keys stable.
|
||||||
function normalizeFilter(filter: FilterNode | undefined): FilterNode | undefined {
|
export function normalizeFilter(filter: FilterNode | undefined): FilterNode | undefined {
|
||||||
if (!filter) return undefined;
|
if (!filter) return undefined;
|
||||||
if ('children' in filter && filter.children.length === 0) return undefined;
|
if ('children' in filter && filter.children.length === 0) return undefined;
|
||||||
return filter;
|
return filter;
|
||||||
@@ -55,6 +59,61 @@ function newRequestId(): string {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function baseRowsQueryKey(
|
||||||
|
pageId: string | undefined,
|
||||||
|
filter: FilterNode | undefined,
|
||||||
|
sorts: ViewSortConfig[] | undefined,
|
||||||
|
search: SearchSpec | undefined,
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
"base-rows",
|
||||||
|
pageId,
|
||||||
|
normalizeFilter(filter),
|
||||||
|
sorts?.length ? sorts : undefined,
|
||||||
|
search?.query ? search : undefined,
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function baseRowsCountKey(
|
||||||
|
pageId: string | undefined,
|
||||||
|
filter: FilterNode | undefined,
|
||||||
|
search: SearchSpec | undefined,
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
"base-rows-count",
|
||||||
|
pageId,
|
||||||
|
normalizeFilter(filter),
|
||||||
|
search?.query ? search : undefined,
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function baseRowGroupCountsKey(
|
||||||
|
pageId: string | undefined,
|
||||||
|
groupByPropertyId: string | undefined,
|
||||||
|
filter: FilterNode | undefined,
|
||||||
|
search: SearchSpec | undefined,
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
"base-row-group-counts",
|
||||||
|
pageId,
|
||||||
|
groupByPropertyId,
|
||||||
|
normalizeFilter(filter),
|
||||||
|
search?.query ? search : undefined,
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findRowInInfinite(
|
||||||
|
data: InfiniteData<IBaseRowsPage> | undefined,
|
||||||
|
rowId: string,
|
||||||
|
): IBaseRow | undefined {
|
||||||
|
if (!data) return undefined;
|
||||||
|
for (const page of data.pages) {
|
||||||
|
const row = page.items.find((r) => r.id === rowId);
|
||||||
|
if (row) return row;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function useBaseRowsQuery(
|
export function useBaseRowsQuery(
|
||||||
pageId: string | undefined,
|
pageId: string | undefined,
|
||||||
filter?: FilterNode,
|
filter?: FilterNode,
|
||||||
@@ -66,7 +125,7 @@ export function useBaseRowsQuery(
|
|||||||
const activeSearch = search?.query ? search : undefined;
|
const activeSearch = search?.query ? search : undefined;
|
||||||
|
|
||||||
const query = useInfiniteQuery({
|
const query = useInfiniteQuery({
|
||||||
queryKey: ["base-rows", pageId, activeFilter, activeSorts, activeSearch],
|
queryKey: baseRowsQueryKey(pageId, filter, sorts, search),
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
listRows(pageId!, {
|
listRows(pageId!, {
|
||||||
cursor: pageParam,
|
cursor: pageParam,
|
||||||
@@ -122,6 +181,10 @@ export function useCreateRowMutation() {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const base = queryClient.getQueryData<IBase>(["bases", newRow.pageId]);
|
||||||
|
if ((base?.views ?? []).some((v) => v.type === "kanban")) {
|
||||||
|
invalidateKanbanColumns(newRow.pageId);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -209,7 +272,7 @@ export function useUpdateRowMutation() {
|
|||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: (updatedRow) => {
|
onSuccess: (updatedRow, variables) => {
|
||||||
queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>(
|
queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>(
|
||||||
{ queryKey: ["base-rows", updatedRow.pageId] },
|
{ queryKey: ["base-rows", updatedRow.pageId] },
|
||||||
(old) => {
|
(old) => {
|
||||||
@@ -234,6 +297,18 @@ export function useUpdateRowMutation() {
|
|||||||
? { ...old, ...updatedRow, cells: { ...old.cells, ...updatedRow.cells } }
|
? { ...old, ...updatedRow, cells: { ...old.cells, ...updatedRow.cells } }
|
||||||
: old,
|
: old,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const base = queryClient.getQueryData<IBase>(["bases", variables.pageId]);
|
||||||
|
const kanbanGroupByIds = new Set(
|
||||||
|
(base?.views ?? [])
|
||||||
|
.filter((v) => v.type === "kanban")
|
||||||
|
.map((v) => v.config?.groupByPropertyId)
|
||||||
|
.filter(Boolean) as string[],
|
||||||
|
);
|
||||||
|
const changedPropertyIds = Object.keys(variables.cells ?? {});
|
||||||
|
if (changedPropertyIds.some((id) => kanbanGroupByIds.has(id))) {
|
||||||
|
invalidateKanbanColumns(variables.pageId);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -336,7 +411,7 @@ export function useBaseRowsCountQuery(
|
|||||||
const activeSearch = search?.query ? search : undefined;
|
const activeSearch = search?.query ? search : undefined;
|
||||||
|
|
||||||
return useQuery<CountRowsResult>({
|
return useQuery<CountRowsResult>({
|
||||||
queryKey: ["base-rows-count", pageId, activeFilter, activeSearch],
|
queryKey: baseRowsCountKey(pageId, filter, search),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
countRows({
|
countRows({
|
||||||
pageId: pageId!,
|
pageId: pageId!,
|
||||||
@@ -348,6 +423,57 @@ export function useBaseRowsCountQuery(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toGroupCountMap(result: GroupCountsResult): Record<string, number> {
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
for (const { value, count } of result.counts) {
|
||||||
|
const key = value == null || value === "" ? NO_VALUE_CHOICE_ID : value;
|
||||||
|
map[key] = (map[key] ?? 0) + count;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBaseRowGroupCountsQuery(
|
||||||
|
pageId: string | undefined,
|
||||||
|
groupByPropertyId: string | undefined,
|
||||||
|
filter?: FilterNode,
|
||||||
|
search?: SearchSpec,
|
||||||
|
) {
|
||||||
|
const activeFilter = normalizeFilter(filter);
|
||||||
|
const activeSearch = search?.query ? search : undefined;
|
||||||
|
|
||||||
|
return useQuery<Record<string, number>>({
|
||||||
|
queryKey: baseRowGroupCountsKey(pageId, groupByPropertyId, filter, search),
|
||||||
|
queryFn: async () =>
|
||||||
|
toGroupCountMap(
|
||||||
|
await groupCountRows({
|
||||||
|
pageId: pageId!,
|
||||||
|
groupByPropertyId: groupByPropertyId!,
|
||||||
|
filter: activeFilter,
|
||||||
|
search: activeSearch,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
enabled: !!pageId && !!groupByPropertyId,
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustGroupCount(
|
||||||
|
pageId: string,
|
||||||
|
groupByPropertyId: string,
|
||||||
|
filter: FilterNode | undefined,
|
||||||
|
search: SearchSpec | undefined,
|
||||||
|
columnKey: string,
|
||||||
|
delta: number,
|
||||||
|
) {
|
||||||
|
queryClient.setQueryData<Record<string, number>>(
|
||||||
|
baseRowGroupCountsKey(pageId, groupByPropertyId, filter, search),
|
||||||
|
(old) =>
|
||||||
|
old
|
||||||
|
? { ...old, [columnKey]: Math.max(0, (old[columnKey] ?? 0) + delta) }
|
||||||
|
: old,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function useReorderRowMutation() {
|
export function useReorderRowMutation() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return useMutation<void, Error, ReorderRowInput, RowCacheContext>({
|
return useMutation<void, Error, ReorderRowInput, RowCacheContext>({
|
||||||
@@ -394,3 +520,168 @@ export function useReorderRowMutation() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type KanbanMoveCardInput = {
|
||||||
|
pageId: string;
|
||||||
|
rowId: string;
|
||||||
|
sourceColumnFilter: FilterNode | undefined;
|
||||||
|
destColumnFilter: FilterNode | undefined;
|
||||||
|
columnChanged: boolean;
|
||||||
|
groupByPropertyId: string;
|
||||||
|
sourceColumnKey: string;
|
||||||
|
destColumnKey: string;
|
||||||
|
destChoiceValue: string | null;
|
||||||
|
position: string;
|
||||||
|
viewFilter?: FilterNode;
|
||||||
|
search?: SearchSpec;
|
||||||
|
};
|
||||||
|
|
||||||
|
type KanbanMoveCardContext = {
|
||||||
|
snapshots: [readonly unknown[], unknown][];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useKanbanMoveCardMutation() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return useMutation<IBaseRow, Error, KanbanMoveCardInput, KanbanMoveCardContext>({
|
||||||
|
mutationFn: ({ pageId, rowId, columnChanged, groupByPropertyId, destChoiceValue, position }) =>
|
||||||
|
updateRow({
|
||||||
|
pageId,
|
||||||
|
rowId,
|
||||||
|
cells: columnChanged ? { [groupByPropertyId]: destChoiceValue } : {},
|
||||||
|
position,
|
||||||
|
requestId: newRequestId(),
|
||||||
|
}),
|
||||||
|
onMutate: async (variables) => {
|
||||||
|
const { pageId, rowId, sourceColumnFilter, destColumnFilter, columnChanged, groupByPropertyId, sourceColumnKey, destColumnKey, destChoiceValue, position, viewFilter, search } = variables;
|
||||||
|
|
||||||
|
await queryClient.cancelQueries({ queryKey: ["base-rows", pageId] });
|
||||||
|
|
||||||
|
const sourceKey = baseRowsQueryKey(pageId, sourceColumnFilter, undefined, search);
|
||||||
|
const destKey = baseRowsQueryKey(pageId, destColumnFilter, undefined, search);
|
||||||
|
|
||||||
|
const groupCountsKey = baseRowGroupCountsKey(pageId, groupByPropertyId, viewFilter, search);
|
||||||
|
const sourceSnapshot = queryClient.getQueryData<InfiniteData<IBaseRowsPage>>(sourceKey);
|
||||||
|
const destSnapshot = queryClient.getQueryData<InfiniteData<IBaseRowsPage>>(destKey);
|
||||||
|
const snapshots: KanbanMoveCardContext["snapshots"] = [
|
||||||
|
[sourceKey, sourceSnapshot],
|
||||||
|
[destKey, destSnapshot],
|
||||||
|
[groupCountsKey, queryClient.getQueryData(groupCountsKey)],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (columnChanged) {
|
||||||
|
queryClient.setQueryData<InfiniteData<IBaseRowsPage>>(sourceKey, (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
pages: old.pages.map((page) => ({
|
||||||
|
...page,
|
||||||
|
items: page.items.filter((r) => r.id !== rowId),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const movingRow = findRowInInfinite(sourceSnapshot, rowId);
|
||||||
|
if (movingRow) {
|
||||||
|
const moved: IBaseRow = {
|
||||||
|
...movingRow,
|
||||||
|
cells: { ...movingRow.cells, [groupByPropertyId]: destChoiceValue },
|
||||||
|
position,
|
||||||
|
};
|
||||||
|
queryClient.setQueryData<InfiniteData<IBaseRowsPage>>(destKey, (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
const lastPageIndex = old.pages.length - 1;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
pages: old.pages.map((page, index) =>
|
||||||
|
index === lastPageIndex
|
||||||
|
? { ...page, items: [...page.items, moved] }
|
||||||
|
: page,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
adjustGroupCount(pageId, groupByPropertyId, viewFilter, search, sourceColumnKey, -1);
|
||||||
|
adjustGroupCount(pageId, groupByPropertyId, viewFilter, search, destColumnKey, 1);
|
||||||
|
} else {
|
||||||
|
queryClient.setQueryData<InfiniteData<IBaseRowsPage>>(destKey, (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
pages: old.pages.map((page) => ({
|
||||||
|
...page,
|
||||||
|
items: page.items.map((r) =>
|
||||||
|
r.id === rowId ? { ...r, position } : r,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { snapshots };
|
||||||
|
},
|
||||||
|
onError: (_, __, context) => {
|
||||||
|
if (context?.snapshots) {
|
||||||
|
for (const [key, data] of context.snapshots) {
|
||||||
|
queryClient.setQueryData(key, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to move card"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type KanbanCreateCardInput = {
|
||||||
|
pageId: string;
|
||||||
|
destColumnFilter: FilterNode | undefined;
|
||||||
|
groupByPropertyId: string;
|
||||||
|
columnKey: string;
|
||||||
|
position?: string;
|
||||||
|
viewFilter?: FilterNode;
|
||||||
|
search?: SearchSpec;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useKanbanCreateCardMutation() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return useMutation<IBaseRow, Error, KanbanCreateCardInput>({
|
||||||
|
mutationFn: ({ pageId, groupByPropertyId, columnKey, position }) =>
|
||||||
|
createRow({
|
||||||
|
pageId,
|
||||||
|
cells: columnKey === NO_VALUE_CHOICE_ID ? {} : { [groupByPropertyId]: columnKey },
|
||||||
|
position,
|
||||||
|
requestId: newRequestId(),
|
||||||
|
}),
|
||||||
|
onSuccess: (newRow, variables) => {
|
||||||
|
const { pageId, destColumnFilter, groupByPropertyId, columnKey, viewFilter, search } = variables;
|
||||||
|
const destKey = baseRowsQueryKey(pageId, destColumnFilter, undefined, search);
|
||||||
|
queryClient.setQueryData<InfiniteData<IBaseRowsPage>>(destKey, (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
const lastPageIndex = old.pages.length - 1;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
pages: old.pages.map((page, index) =>
|
||||||
|
index === lastPageIndex
|
||||||
|
? { ...page, items: [...page.items, newRow] }
|
||||||
|
: page,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
adjustGroupCount(pageId, groupByPropertyId, viewFilter, search, columnKey, 1);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to add card"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateKanbanColumns(pageId: string) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["base-rows-count", pageId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["base-row-group-counts", pageId] });
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
ViewSortConfig,
|
ViewSortConfig,
|
||||||
CountRowsInput,
|
CountRowsInput,
|
||||||
CountRowsResult,
|
CountRowsResult,
|
||||||
|
GroupCountsInput,
|
||||||
|
GroupCountsResult,
|
||||||
RowReferences,
|
RowReferences,
|
||||||
} from "@/ee/base/types/base.types";
|
} from "@/ee/base/types/base.types";
|
||||||
import { IPagination } from "@/lib/types";
|
import { IPagination } from "@/lib/types";
|
||||||
@@ -33,8 +35,6 @@ export type IBaseRowsPage = IPagination<IBaseRow> & {
|
|||||||
references?: RowReferences;
|
references?: RowReferences;
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Bases ---
|
|
||||||
|
|
||||||
export async function createBase(data: CreateBaseInput): Promise<IBase> {
|
export async function createBase(data: CreateBaseInput): Promise<IBase> {
|
||||||
const req = await api.post<IBase>("/bases/create", data);
|
const req = await api.post<IBase>("/bases/create", data);
|
||||||
return req.data;
|
return req.data;
|
||||||
@@ -54,8 +54,11 @@ export async function deleteBase(pageId: string): Promise<void> {
|
|||||||
await api.post("/bases/delete", { pageId });
|
await api.post("/bases/delete", { pageId });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function convertPageToBase(pageId: string): Promise<IBase> {
|
export async function convertPageToBase(
|
||||||
const req = await api.post<IBase>("/bases/convert", { pageId });
|
pageId: string,
|
||||||
|
template?: "kanban",
|
||||||
|
): Promise<IBase> {
|
||||||
|
const req = await api.post<IBase>("/bases/convert", { pageId, template });
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,8 +90,6 @@ export async function listBases(
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Properties ---
|
|
||||||
|
|
||||||
export async function createProperty(
|
export async function createProperty(
|
||||||
data: CreatePropertyInput,
|
data: CreatePropertyInput,
|
||||||
): Promise<IBaseProperty> {
|
): Promise<IBaseProperty> {
|
||||||
@@ -116,8 +117,6 @@ export async function reorderProperty(
|
|||||||
await api.post("/bases/properties/reorder", data);
|
await api.post("/bases/properties/reorder", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Rows ---
|
|
||||||
|
|
||||||
export async function createRow(data: CreateRowInput): Promise<IBaseRow> {
|
export async function createRow(data: CreateRowInput): Promise<IBaseRow> {
|
||||||
const req = await api.post<IBaseRow>("/bases/rows/create", data);
|
const req = await api.post<IBaseRow>("/bases/rows/create", data);
|
||||||
return req.data;
|
return req.data;
|
||||||
@@ -169,6 +168,16 @@ export async function countRows(
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function groupCountRows(
|
||||||
|
data: GroupCountsInput,
|
||||||
|
): Promise<GroupCountsResult> {
|
||||||
|
const req = await api.post<GroupCountsResult>(
|
||||||
|
"/bases/rows/group-counts",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Views ---
|
// --- Views ---
|
||||||
|
|
||||||
export async function createView(data: CreateViewInput): Promise<IBaseView> {
|
export async function createView(data: CreateViewInput): Promise<IBaseView> {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { NO_VALUE_CHOICE_ID, FilterGroup, FilterNode } from "@/ee/base/types/base.types";
|
||||||
|
import { normalizeFilter } from "@/ee/base/queries/base-row-query";
|
||||||
|
|
||||||
|
export function buildColumnFilter(
|
||||||
|
viewFilter: FilterGroup | undefined,
|
||||||
|
groupByPropertyId: string,
|
||||||
|
columnKey: string,
|
||||||
|
): FilterNode | undefined {
|
||||||
|
const condition = columnKey === NO_VALUE_CHOICE_ID
|
||||||
|
? { propertyId: groupByPropertyId, op: "isEmpty" as const }
|
||||||
|
: { propertyId: groupByPropertyId, op: "eq" as const, value: columnKey };
|
||||||
|
const children: FilterGroup["children"] = viewFilter?.children?.length
|
||||||
|
? [viewFilter, condition]
|
||||||
|
: [condition];
|
||||||
|
return normalizeFilter({ op: "and", children });
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
.standalone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandWrap {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
padding: 8px var(--embed-grid-pad-right, 12px) 8px var(--embed-grid-pad-left, 12px);
|
||||||
|
margin-left: calc(-1 * var(--embed-extend-l, 0px));
|
||||||
|
margin-right: calc(-1 * var(--embed-extend-r, 0px));
|
||||||
|
overflow-anchor: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boardEmbed {
|
||||||
|
max-height: min(80vh, calc(100vh - var(--sticky-band-top, 0px)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.boardFullPage {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 280px;
|
||||||
|
min-height: 0;
|
||||||
|
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columnHeader {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 10px 6px;
|
||||||
|
border-top-left-radius: 10px;
|
||||||
|
border-top-right-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columnHeaderDragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columnDragHandle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: -4px;
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
cursor: grab;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columnHeader:hover .columnDragHandle {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columnDragHandle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-anchor: none;
|
||||||
|
padding: 4px 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||||
|
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 2px light-dark(rgba(0, 0, 0, 0.06), rgba(0, 0, 0, 0.25));
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardDragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:focus-visible {
|
||||||
|
outline: 2px solid var(--mantine-color-blue-5);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTitle {
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.3;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardUntitled {
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addCard {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addCard:hover {
|
||||||
|
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.addCard:focus-visible {
|
||||||
|
outline: 2px solid var(--mantine-color-blue-5);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardDragPreview {
|
||||||
|
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||||
|
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
@@ -193,8 +193,7 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Field shell ----------
|
/* Every value renders inside the same shell: identical padding, identical
|
||||||
Every value renders inside the same shell: identical padding, identical
|
|
||||||
left edge, identical focus treatment. Fields differ only in content. */
|
left edge, identical focus treatment. Fields differ only in content. */
|
||||||
|
|
||||||
.fieldShell {
|
.fieldShell {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export type BasePropertyType =
|
|||||||
| 'formula'
|
| 'formula'
|
||||||
| 'longText';
|
| 'longText';
|
||||||
|
|
||||||
|
export type BaseViewType = 'table' | 'kanban' | 'calendar';
|
||||||
|
|
||||||
export type Choice = {
|
export type Choice = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -193,6 +195,10 @@ export type SearchSpec = {
|
|||||||
|
|
||||||
export const NO_VALUE_CHOICE_ID = '__no_value';
|
export const NO_VALUE_CHOICE_ID = '__no_value';
|
||||||
|
|
||||||
|
export const KANBAN_CARD_DRAG_TYPE = "base-kanban-card";
|
||||||
|
export const KANBAN_COLUMN_DRAG_TYPE = "base-kanban-column";
|
||||||
|
export type KanbanColumn = { key: string; name: string; color?: string; isNoValue: boolean };
|
||||||
|
|
||||||
export type ViewConfig = {
|
export type ViewConfig = {
|
||||||
sorts?: ViewSortConfig[];
|
sorts?: ViewSortConfig[];
|
||||||
filter?: FilterGroup;
|
filter?: FilterGroup;
|
||||||
@@ -214,7 +220,7 @@ export type IBaseView = {
|
|||||||
id: string;
|
id: string;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'table' | 'kanban' | 'calendar';
|
type: BaseViewType;
|
||||||
config: ViewConfig;
|
config: ViewConfig;
|
||||||
position: string;
|
position: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -301,10 +307,22 @@ export type CountRowsResult = {
|
|||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GroupCountsInput = {
|
||||||
|
pageId: string;
|
||||||
|
groupByPropertyId: string;
|
||||||
|
filter?: FilterNode;
|
||||||
|
search?: SearchSpec;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GroupCountsResult = {
|
||||||
|
counts: Array<{ value: string | null; count: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
export type CreateRowInput = {
|
export type CreateRowInput = {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
cells?: Record<string, unknown>;
|
cells?: Record<string, unknown>;
|
||||||
afterRowId?: string;
|
afterRowId?: string;
|
||||||
|
position?: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -338,7 +356,7 @@ export type ReorderRowInput = {
|
|||||||
export type CreateViewInput = {
|
export type CreateViewInput = {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
name: string;
|
name: string;
|
||||||
type?: 'table' | 'kanban' | 'calendar';
|
type?: BaseViewType;
|
||||||
config?: ViewConfig;
|
config?: ViewConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -346,7 +364,7 @@ export type UpdateViewInput = {
|
|||||||
viewId: string;
|
viewId: string;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
type?: 'table' | 'kanban' | 'calendar';
|
type?: BaseViewType;
|
||||||
config?: ViewConfigPatch;
|
config?: ViewConfigPatch;
|
||||||
position?: string;
|
position?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
|
|||||||
|
|
||||||
export const yjsConnectionStatusAtom = atom<string>("");
|
export const yjsConnectionStatusAtom = atom<string>("");
|
||||||
|
|
||||||
|
export const yjsSyncedAtom = atom<boolean>(false);
|
||||||
|
|
||||||
export const showAiMenuAtom = atom(false);
|
export const showAiMenuAtom = atom(false);
|
||||||
|
|
||||||
export const showLinkMenuAtom = atom(false);
|
export const showLinkMenuAtom = atom(false);
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { useBaseQuery } from "@/ee/base/queries/base-query";
|
|||||||
import { pinOffsetWatcher } from "@docmost/editor-ext";
|
import { pinOffsetWatcher } from "@docmost/editor-ext";
|
||||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||||
import { Feature } from "@/ee/features";
|
import { Feature } from "@/ee/features";
|
||||||
|
import { IconTable } from "@tabler/icons-react";
|
||||||
|
import { usePageQuery } from "@/features/page/queries/page-query";
|
||||||
|
import classes from "./base-embed.module.css";
|
||||||
|
|
||||||
const SIDE_GUTTER = 8;
|
const SIDE_GUTTER = 8;
|
||||||
|
|
||||||
@@ -56,6 +59,7 @@ export function BaseEmbedView({ node, editor }: NodeViewProps) {
|
|||||||
const { data: base, isLoading, isError } = useBaseQuery(
|
const { data: base, isLoading, isError } = useBaseQuery(
|
||||||
pendingKey ? "" : pageId ?? "",
|
pendingKey ? "" : pageId ?? "",
|
||||||
);
|
);
|
||||||
|
const { data: page } = usePageQuery({ pageId: pageId ?? undefined });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wrapper = wrapperRef.current;
|
const wrapper = wrapperRef.current;
|
||||||
@@ -129,7 +133,11 @@ export function BaseEmbedView({ node, editor }: NodeViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper>
|
<NodeViewWrapper className={classes.handleGutter}>
|
||||||
|
<div data-drag-preview hidden className={classes.dragPreview}>
|
||||||
|
<IconTable size={16} />
|
||||||
|
<span>{page?.title?.trim() || "Untitled base"}</span>
|
||||||
|
</div>
|
||||||
<div ref={wrapperRef} style={{ minHeight: isCompact ? undefined : 200 }}>
|
<div ref={wrapperRef} style={{ minHeight: isCompact ? undefined : 200 }}>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
.handleGutter {
|
||||||
|
margin-left: -1.5rem;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 48em) {
|
||||||
|
.handleGutter {
|
||||||
|
margin-left: -1rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragPreview {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 260px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-white),
|
||||||
|
var(--mantine-color-dark-6)
|
||||||
|
);
|
||||||
|
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
|
||||||
|
border: 1px solid
|
||||||
|
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragPreview[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragPreview svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragPreview span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button } from "@mantine/core";
|
import { Button } from "@mantine/core";
|
||||||
import { IconTable } from "@tabler/icons-react";
|
import { IconTable, IconLayoutKanban } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useConvertPageToBaseMutation } from "@/ee/base/queries/base-query";
|
import { useConvertPageToBaseMutation } from "@/ee/base/queries/base-query";
|
||||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
import {
|
||||||
|
pageEditorAtom,
|
||||||
|
yjsSyncedAtom,
|
||||||
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||||
import { Feature } from "@/ee/features";
|
import { Feature } from "@/ee/features";
|
||||||
import classes from "./empty-page-get-started.module.css";
|
import classes from "./empty-page-get-started.module.css";
|
||||||
@@ -20,6 +23,7 @@ export function EmptyPageGetStarted({
|
|||||||
}: EmptyPageGetStartedProps) {
|
}: EmptyPageGetStartedProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const editor = useAtomValue(pageEditorAtom);
|
const editor = useAtomValue(pageEditorAtom);
|
||||||
|
const isSynced = useAtomValue(yjsSyncedAtom);
|
||||||
const hasBases = useHasFeature(Feature.BASES);
|
const hasBases = useHasFeature(Feature.BASES);
|
||||||
const convertMutation = useConvertPageToBaseMutation();
|
const convertMutation = useConvertPageToBaseMutation();
|
||||||
|
|
||||||
@@ -36,7 +40,7 @@ export function EmptyPageGetStarted({
|
|||||||
};
|
};
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
if (!editable || !hasBases || !editor || !isEmpty) return null;
|
if (!editable || !hasBases || !editor || !isSynced || !isEmpty) return null;
|
||||||
|
|
||||||
const chips = [
|
const chips = [
|
||||||
{
|
{
|
||||||
@@ -46,6 +50,13 @@ export function EmptyPageGetStarted({
|
|||||||
onClick: () => convertMutation.mutate({ pageId }),
|
onClick: () => convertMutation.mutate({ pageId }),
|
||||||
disabled: convertMutation.isPending,
|
disabled: convertMutation.isPending,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "kanban",
|
||||||
|
label: t("Kanban"),
|
||||||
|
icon: IconLayoutKanban,
|
||||||
|
onClick: () => convertMutation.mutate({ pageId, template: "kanban" }),
|
||||||
|
disabled: convertMutation.isPending,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
IconH2,
|
IconH2,
|
||||||
IconH3,
|
IconH3,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
|
IconLayoutKanban,
|
||||||
IconList,
|
IconList,
|
||||||
IconListNumbers,
|
IconListNumbers,
|
||||||
IconMath,
|
IconMath,
|
||||||
@@ -443,6 +444,62 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Kanban",
|
||||||
|
description: "Insert a kanban board on this page",
|
||||||
|
searchTerms: ["kanban", "board", "cards", "status", "task"],
|
||||||
|
icon: IconLayoutKanban,
|
||||||
|
command: async ({ editor, range }: CommandProps) => {
|
||||||
|
// @ts-ignore
|
||||||
|
const parentPageId = editor.storage?.pageId as string | undefined;
|
||||||
|
if (!parentPageId) return;
|
||||||
|
|
||||||
|
const pendingKey = uuid7();
|
||||||
|
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.insertBaseEmbed({ pageId: null, pendingKey })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.post<{ id: string }>("/bases/create", {
|
||||||
|
parentPageId,
|
||||||
|
template: "kanban",
|
||||||
|
});
|
||||||
|
|
||||||
|
const pos = findBaseEmbedPlaceholderPos(editor, pendingKey);
|
||||||
|
if (pos === null) return;
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.command(({ tr }) => {
|
||||||
|
tr.setNodeMarkup(pos, undefined, {
|
||||||
|
pageId: res.data.id,
|
||||||
|
pendingKey: null,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} catch {
|
||||||
|
const pos = findBaseEmbedPlaceholderPos(editor, pendingKey);
|
||||||
|
if (pos !== null) {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.command(({ tr }) => {
|
||||||
|
const node = tr.doc.nodeAt(pos);
|
||||||
|
if (node) tr.delete(pos, pos + node.nodeSize);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
notifications.show({
|
||||||
|
message: "Failed to create base",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Toggle block",
|
title: "Toggle block",
|
||||||
description: "Insert collapsible block.",
|
description: "Insert collapsible block.",
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface GlobalDragHandleOptions {
|
|||||||
* Custom nodes to be included for drag handle
|
* Custom nodes to be included for drag handle
|
||||||
*/
|
*/
|
||||||
customNodes: string[];
|
customNodes: string[];
|
||||||
|
|
||||||
|
atomNodes: string[];
|
||||||
}
|
}
|
||||||
function absoluteRect(node: Element) {
|
function absoluteRect(node: Element) {
|
||||||
const data = node.getBoundingClientRect();
|
const data = node.getBoundingClientRect();
|
||||||
@@ -76,6 +78,10 @@ function nodeDOMAtCoords(
|
|||||||
`[data-type=${node}] p`,
|
`[data-type=${node}] p`,
|
||||||
`.node-${node} p`,
|
`.node-${node} p`,
|
||||||
]);
|
]);
|
||||||
|
const atomSelectors = options.atomNodes.flatMap((node) => [
|
||||||
|
`[data-type=${node}]`,
|
||||||
|
`.node-${node}`,
|
||||||
|
]);
|
||||||
|
|
||||||
const selectors = [
|
const selectors = [
|
||||||
"li",
|
"li",
|
||||||
@@ -95,8 +101,9 @@ function nodeDOMAtCoords(
|
|||||||
".tableWrapper",
|
".tableWrapper",
|
||||||
...customParagraphSelectors,
|
...customParagraphSelectors,
|
||||||
...customSelectors,
|
...customSelectors,
|
||||||
|
...atomSelectors,
|
||||||
].join(", ");
|
].join(", ");
|
||||||
return document
|
const found = document
|
||||||
.elementsFromPoint(coords.x, coords.y)
|
.elementsFromPoint(coords.x, coords.y)
|
||||||
.find((elem: Element) => {
|
.find((elem: Element) => {
|
||||||
// Skip elements that belong to a nested editor (e.g. transclusion
|
// Skip elements that belong to a nested editor (e.g. transclusion
|
||||||
@@ -108,6 +115,11 @@ function nodeDOMAtCoords(
|
|||||||
elem.matches(selectors)
|
elem.matches(selectors)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
if (found && atomSelectors.length > 0) {
|
||||||
|
const atomWrapper = found.closest(atomSelectors.join(", "));
|
||||||
|
if (atomWrapper) return atomWrapper;
|
||||||
|
}
|
||||||
|
return found;
|
||||||
}
|
}
|
||||||
function nodePosAtDOM(
|
function nodePosAtDOM(
|
||||||
node: Element,
|
node: Element,
|
||||||
@@ -127,7 +139,7 @@ function isCustomNodeDOM(
|
|||||||
options: GlobalDragHandleOptions,
|
options: GlobalDragHandleOptions,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!elem) return false;
|
if (!elem) return false;
|
||||||
for (const name of options.customNodes) {
|
for (const name of [...options.customNodes, ...options.atomNodes]) {
|
||||||
if (
|
if (
|
||||||
elem.getAttribute("data-type") === name ||
|
elem.getAttribute("data-type") === name ||
|
||||||
elem.classList.contains(`node-${name}`)
|
elem.classList.contains(`node-${name}`)
|
||||||
@@ -210,7 +222,10 @@ export function DragHandlePlugin(
|
|||||||
// The drag landed on a custom-node container (transclusion etc.).
|
// The drag landed on a custom-node container (transclusion etc.).
|
||||||
// Walk up to the matching node so the drag moves the whole
|
// Walk up to the matching node so the drag moves the whole
|
||||||
// container, not whatever inner element the click landed on.
|
// container, not whatever inner element the click landed on.
|
||||||
const customTypes = new Set(options.customNodes);
|
const customTypes = new Set([
|
||||||
|
...options.customNodes,
|
||||||
|
...options.atomNodes,
|
||||||
|
]);
|
||||||
for (let d = $sel.depth; d > 0; d--) {
|
for (let d = $sel.depth; d > 0; d--) {
|
||||||
if (customTypes.has($sel.node(d).type.name)) {
|
if (customTypes.has($sel.node(d).type.name)) {
|
||||||
selection = NodeSelection.create(
|
selection = NodeSelection.create(
|
||||||
@@ -264,7 +279,23 @@ export function DragHandlePlugin(
|
|||||||
event.dataTransfer.setData("text/plain", text);
|
event.dataTransfer.setData("text/plain", text);
|
||||||
event.dataTransfer.effectAllowed = "move";
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
|
||||||
event.dataTransfer.setDragImage(node, 0, 0);
|
const previewTemplate =
|
||||||
|
node.querySelector<HTMLElement>("[data-drag-preview]");
|
||||||
|
if (previewTemplate) {
|
||||||
|
const preview = previewTemplate.cloneNode(true) as HTMLElement;
|
||||||
|
preview.removeAttribute("hidden");
|
||||||
|
preview.style.position = "fixed";
|
||||||
|
preview.style.top = "0";
|
||||||
|
preview.style.left = "-10000px";
|
||||||
|
preview.style.pointerEvents = "none";
|
||||||
|
document.body.appendChild(preview);
|
||||||
|
event.dataTransfer.setDragImage(preview, 0, 0);
|
||||||
|
document.addEventListener("dragend", () => preview.remove(), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
event.dataTransfer.setDragImage(node, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
view.dragging = { slice, move: event.ctrlKey };
|
view.dragging = { slice, move: event.ctrlKey };
|
||||||
}
|
}
|
||||||
@@ -497,6 +528,7 @@ const GlobalDragHandle = Extension.create({
|
|||||||
scrollThreshold: 100,
|
scrollThreshold: 100,
|
||||||
excludedTags: [],
|
excludedTags: [],
|
||||||
customNodes: [],
|
customNodes: [],
|
||||||
|
atomNodes: [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -509,6 +541,7 @@ const GlobalDragHandle = Extension.create({
|
|||||||
dragHandleSelector: this.options.dragHandleSelector,
|
dragHandleSelector: this.options.dragHandleSelector,
|
||||||
excludedTags: this.options.excludedTags,
|
excludedTags: this.options.excludedTags,
|
||||||
customNodes: this.options.customNodes,
|
customNodes: this.options.customNodes,
|
||||||
|
atomNodes: this.options.atomNodes,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ export const mainExtensions = [
|
|||||||
TrailingNode,
|
TrailingNode,
|
||||||
GlobalDragHandle.configure({
|
GlobalDragHandle.configure({
|
||||||
customNodes: ["transclusionSource", "transclusionReference"],
|
customNodes: ["transclusionSource", "transclusionReference"],
|
||||||
|
atomNodes: ["base"],
|
||||||
}),
|
}),
|
||||||
TextStyle,
|
TextStyle,
|
||||||
Color,
|
Color,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
currentPageEditModeAtom,
|
currentPageEditModeAtom,
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
|
yjsSyncedAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||||
import {
|
import {
|
||||||
@@ -109,6 +110,7 @@ export default function PageEditor({
|
|||||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
);
|
);
|
||||||
|
const [, setYjsSynced] = useAtom(yjsSyncedAtom);
|
||||||
const menuContainerRef = useRef(null);
|
const menuContainerRef = useRef(null);
|
||||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
||||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||||
@@ -378,6 +380,14 @@ export default function PageEditor({
|
|||||||
|
|
||||||
const isSynced = isLocalSynced && isRemoteSynced;
|
const isSynced = isLocalSynced && isRemoteSynced;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setYjsSynced(isSynced);
|
||||||
|
}, [isSynced, setYjsSynced]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => setYjsSynced(false);
|
||||||
|
}, [setYjsSynced]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
|
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
|
||||||
|
|||||||
@@ -87,8 +87,6 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- pragmatic-tree additions ---- */
|
|
||||||
|
|
||||||
.rowWrapper {
|
.rowWrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
|||||||
// Height: see `.base-page-root` in core.css.
|
// Height: see `.base-page-root` in core.css.
|
||||||
// Clear the fixed PageHeader (breadcrumb) plus a little extra so the
|
// Clear the fixed PageHeader (breadcrumb) plus a little extra so the
|
||||||
// pinned column-header row isn't tucked half under it.
|
// pinned column-header row isn't tucked half under it.
|
||||||
paddingTop: "calc(var(--page-header-height) + 18px)",
|
paddingTop: "calc(var(--page-header-height) + 6px)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
@@ -137,7 +137,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
|||||||
titleSlot={
|
titleSlot={
|
||||||
<div
|
<div
|
||||||
className="base-page-title"
|
className="base-page-title"
|
||||||
style={{ paddingTop: 32, paddingBottom: 12 }}
|
style={{ paddingTop: 2, paddingBottom: 6 }}
|
||||||
>
|
>
|
||||||
<MemoizedTitleEditor
|
<MemoizedTitleEditor
|
||||||
pageId={page.id}
|
pageId={page.id}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"@aws-sdk/s3-request-presigner": "3.1050.0",
|
"@aws-sdk/s3-request-presigner": "3.1050.0",
|
||||||
"@azure/storage-blob": "12.31.0",
|
"@azure/storage-blob": "12.31.0",
|
||||||
"@clickhouse/client": "^1.18.2",
|
"@clickhouse/client": "^1.18.2",
|
||||||
|
"@docmost/base-formula": "workspace:*",
|
||||||
"@docmost/pdf-inspector": "1.9.6",
|
"@docmost/pdf-inspector": "1.9.6",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^10.0.0",
|
"@fastify/multipart": "^10.0.0",
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: 73a853e237...c89c87e4f4
Generated
+3
@@ -513,6 +513,9 @@ importers:
|
|||||||
'@clickhouse/client':
|
'@clickhouse/client':
|
||||||
specifier: ^1.18.2
|
specifier: ^1.18.2
|
||||||
version: 1.18.2
|
version: 1.18.2
|
||||||
|
'@docmost/base-formula':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/base-formula
|
||||||
'@docmost/pdf-inspector':
|
'@docmost/pdf-inspector':
|
||||||
specifier: 1.9.6
|
specifier: 1.9.6
|
||||||
version: 1.9.6
|
version: 1.9.6
|
||||||
|
|||||||
Reference in New Issue
Block a user