mirror of
https://github.com/docmost/docmost.git
synced 2026-06-13 19:19:53 +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 --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/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 --from=builder /app/package.json /app/package.json
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
IconEye,
|
||||
IconDownload,
|
||||
IconArrowsDiagonal,
|
||||
IconLayoutColumns,
|
||||
IconAdjustments,
|
||||
} from "@tabler/icons-react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
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 { ViewFilterConfigPopover } from "@/ee/base/components/views/view-filter-config";
|
||||
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 classes from "@/ee/base/styles/grid.module.css";
|
||||
import toolbarClasses from "@/ee/base/styles/base-toolbar.module.css";
|
||||
|
||||
type BaseToolbarProps = {
|
||||
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;
|
||||
views: IBaseView[];
|
||||
table: Table<IBaseRow>;
|
||||
table?: Table<IBaseRow>;
|
||||
onViewChange: (viewId: string) => void;
|
||||
onAddView?: () => void;
|
||||
canAddView?: boolean;
|
||||
onPersistViewConfig: () => void;
|
||||
onDraftSortsChange: (sorts: ViewSortConfig[] | undefined) => void;
|
||||
onDraftFiltersChange: (filter: FilterGroup | undefined) => void;
|
||||
@@ -50,6 +53,7 @@ export function BaseToolbar({
|
||||
table,
|
||||
onViewChange,
|
||||
onAddView,
|
||||
canAddView,
|
||||
onPersistViewConfig,
|
||||
onDraftSortsChange,
|
||||
onDraftFiltersChange,
|
||||
@@ -61,8 +65,11 @@ export function BaseToolbar({
|
||||
const [sortOpened, setSortOpened] = useState(false);
|
||||
const [filterOpened, setFilterOpened] = useState(false);
|
||||
const [propertiesOpened, setPropertiesOpened] = useState(false);
|
||||
const [cardPropertiesOpened, setCardPropertiesOpened] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const isKanban = activeView?.type === "kanban";
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
if (exporting) return;
|
||||
setExporting(true);
|
||||
@@ -85,8 +92,6 @@ export function BaseToolbar({
|
||||
}, []);
|
||||
|
||||
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 filter = activeView?.config?.filter;
|
||||
if (!filter || filter.op !== "and") return [];
|
||||
@@ -96,13 +101,13 @@ export function BaseToolbar({
|
||||
}, [activeView?.config?.filter]);
|
||||
|
||||
const hiddenPropertyCount = useMemo(() => {
|
||||
if (!table) return 0;
|
||||
const cols = table.getAllLeafColumns().filter((col) => col.id !== "__row_number");
|
||||
return cols.filter((col) => col.getCanHide() && !col.getIsVisible()).length;
|
||||
}, [table, table.getState().columnVisibility]);
|
||||
}, [table, table?.getState().columnVisibility]);
|
||||
|
||||
const handleSortsChange = useCallback(
|
||||
(newSorts: ViewSortConfig[]) => {
|
||||
// Normalize empty to undefined so the draft hook drops the sorts axis.
|
||||
onDraftSortsChange(newSorts.length > 0 ? newSorts : undefined);
|
||||
},
|
||||
[onDraftSortsChange],
|
||||
@@ -110,7 +115,6 @@ export function BaseToolbar({
|
||||
|
||||
const handleFiltersChange = useCallback(
|
||||
(newConditions: FilterCondition[]) => {
|
||||
// Wrap the AND-flat list into the engine's FilterGroup shape; undefined drops the axis.
|
||||
const filter: FilterGroup | undefined =
|
||||
newConditions.length > 0
|
||||
? { op: "and", children: newConditions }
|
||||
@@ -128,6 +132,8 @@ export function BaseToolbar({
|
||||
pageId={base.id}
|
||||
onViewChange={onViewChange}
|
||||
onAddView={onAddView}
|
||||
base={base}
|
||||
canAddView={canAddView}
|
||||
getViewShareUrl={getViewShareUrl}
|
||||
/>
|
||||
|
||||
@@ -175,63 +181,104 @@ export function BaseToolbar({
|
||||
</Tooltip>
|
||||
</ViewFilterConfigPopover>
|
||||
|
||||
<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}
|
||||
{isKanban && activeView && (
|
||||
<>
|
||||
<KanbanGroupByPicker base={base} view={activeView} pageId={base.id}>
|
||||
<Tooltip label={t("Group by")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
>
|
||||
{sorts.length}
|
||||
</Badge>
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ViewSortConfigPopover>
|
||||
<IconLayoutColumns size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</KanbanGroupByPicker>
|
||||
|
||||
<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")}
|
||||
<KanbanCardProperties
|
||||
opened={cardPropertiesOpened}
|
||||
onClose={() => setCardPropertiesOpened(false)}
|
||||
base={base}
|
||||
view={activeView}
|
||||
pageId={base.id}
|
||||
>
|
||||
<IconEye size={16} />
|
||||
{hiddenPropertyCount > 0 && (
|
||||
<Badge
|
||||
size="xs"
|
||||
circle
|
||||
color="blue"
|
||||
className={toolbarClasses.badgeDot}
|
||||
<Tooltip label={t("Card properties")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
onClick={() => setCardPropertiesOpened((v) => !v)}
|
||||
>
|
||||
{hiddenPropertyCount}
|
||||
</Badge>
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ViewPropertyVisibility>
|
||||
<IconAdjustments size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</KanbanCardProperties>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!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 && (
|
||||
<Tooltip label={t("Open as page")}>
|
||||
|
||||
@@ -22,10 +22,7 @@ import {
|
||||
useUpdateRowMutation,
|
||||
useReorderRowMutation,
|
||||
} from "@/ee/base/queries/base-row-query";
|
||||
import {
|
||||
useCreateViewMutation,
|
||||
useUpdateViewMutation,
|
||||
} from "@/ee/base/queries/base-view-query";
|
||||
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
|
||||
import {
|
||||
activeViewIdAtomFamily,
|
||||
editingCellAtomFamily,
|
||||
@@ -51,6 +48,7 @@ import { getAppUrl } from "@/lib/config.ts";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import classes from "@/ee/base/styles/grid.module.css";
|
||||
import viewClasses from "@/ee/base/styles/base-view.module.css";
|
||||
import kanbanClasses from "@/ee/base/styles/kanban.module.css";
|
||||
|
||||
type BaseViewProps = {
|
||||
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
|
||||
// config resolves, which would double network traffic for sorted/filtered views.
|
||||
const isKanban = activeView?.type === "kanban";
|
||||
|
||||
const {
|
||||
data: rowsData,
|
||||
isLoading: rowsLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
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
|
||||
// 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 createRowMutation = useCreateRowMutation();
|
||||
const reorderRowMutation = useReorderRowMutation();
|
||||
const createViewMutation = useCreateViewMutation();
|
||||
const updateViewMutation = useUpdateViewMutation();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -263,15 +262,6 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
|
||||
[setActiveViewId],
|
||||
);
|
||||
|
||||
const handleAddView = useCallback(() => {
|
||||
if (!editable) return;
|
||||
createViewMutation.mutate({
|
||||
pageId,
|
||||
name: t("New view"),
|
||||
type: "table",
|
||||
});
|
||||
}, [editable, pageId, createViewMutation, t]);
|
||||
|
||||
const handleColumnReorder = useCallback(
|
||||
(columnId: string, finishIndex: number) => {
|
||||
const order = table.getState().columnOrder;
|
||||
@@ -374,7 +364,7 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
|
||||
[editable, pageId, reorderRow],
|
||||
);
|
||||
|
||||
if (baseLoading || rowsLoading) {
|
||||
if (baseLoading || (!isKanban && rowsLoading)) {
|
||||
return <BaseTableSkeleton />;
|
||||
}
|
||||
if (baseError) {
|
||||
@@ -407,7 +397,7 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
|
||||
views={views}
|
||||
table={table}
|
||||
onViewChange={handleViewChange}
|
||||
onAddView={editable ? handleAddView : undefined}
|
||||
canAddView={editable}
|
||||
onPersistViewConfig={guardedPersistViewConfig}
|
||||
onDraftSortsChange={handleDraftSortsChange}
|
||||
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 (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;
|
||||
// only the column-header row stays pinned (via --sticky-band-top).
|
||||
return (
|
||||
<BaseEditableProvider editable={editable}>
|
||||
<RowExpandProvider value={handleExpandRow}>
|
||||
<ViewRenderer
|
||||
base={base}
|
||||
rows={rows}
|
||||
effectiveView={effectiveView}
|
||||
table={table}
|
||||
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} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{viewRenderer(
|
||||
<>
|
||||
{banner}
|
||||
{toolbar}
|
||||
<BaseEmbedTitle pageId={pageId} />
|
||||
</>,
|
||||
)}
|
||||
</RowExpandProvider>
|
||||
<RowDetailModal
|
||||
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
|
||||
// container so they scroll away; only the column-header row stays pinned.
|
||||
return (
|
||||
@@ -467,32 +510,13 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
|
||||
<div className={viewClasses.fullHeight}>
|
||||
<div className={classes.tableScrollport} ref={scrollportRef}>
|
||||
<RowExpandProvider value={handleExpandRow}>
|
||||
<ViewRenderer
|
||||
base={base}
|
||||
rows={rows}
|
||||
effectiveView={effectiveView}
|
||||
table={table}
|
||||
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}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{viewRenderer(
|
||||
<>
|
||||
{titleSlot}
|
||||
{banner}
|
||||
{toolbar}
|
||||
</>,
|
||||
)}
|
||||
</RowExpandProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
|
||||
type CellCreatedAtProps = {
|
||||
@@ -10,21 +11,8 @@ type CellCreatedAtProps = {
|
||||
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) {
|
||||
const formatted = formatTimestamp(value);
|
||||
const formatted = formatTimestamp(typeof value === "string" ? value : null);
|
||||
|
||||
if (!formatted) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
|
||||
type CellLastEditedAtProps = {
|
||||
@@ -10,21 +11,8 @@ type CellLastEditedAtProps = {
|
||||
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) {
|
||||
const formatted = formatTimestamp(value);
|
||||
const formatted = formatTimestamp(typeof value === "string" ? value : null);
|
||||
|
||||
if (!formatted) {
|
||||
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 { useDebouncedCallback } from "@mantine/hooks";
|
||||
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";
|
||||
|
||||
type CellLongTextProps = {
|
||||
@@ -68,7 +69,7 @@ export function CellLongText({
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const preview = toText(value).replace(/\s+/g, " ").trim();
|
||||
const preview = formatLongTextPreview(toText(value));
|
||||
|
||||
return (
|
||||
<Popover
|
||||
|
||||
@@ -5,14 +5,13 @@ import clsx from "clsx";
|
||||
import {
|
||||
IBaseProperty,
|
||||
PersonTypeOptions,
|
||||
UserRef,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import {
|
||||
useReferenceStore,
|
||||
useHydrateUsers,
|
||||
} from "@/ee/base/reference/reference-store";
|
||||
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 { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
|
||||
import { usePersonSearch } from "@/ee/base/hooks/use-person-search";
|
||||
@@ -245,38 +244,3 @@ export function CellPerson({
|
||||
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 }> = {
|
||||
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" },
|
||||
grape: { bg: "#f3d9fa", bgDark: "#3b1a4a", text: "#862e9c", textDark: "#e599f7" },
|
||||
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 = {
|
||||
pageId: string;
|
||||
properties?: IBaseProperty[];
|
||||
onPropertyCreated?: () => void;
|
||||
onPropertyCreated?: (property: IBaseProperty) => void;
|
||||
/** Custom trigger; must return a ref-forwarding element for Popover.Target.
|
||||
* Defaults to the grid's + column button. */
|
||||
renderTarget?: (open: () => void) => React.ReactElement;
|
||||
@@ -145,8 +145,8 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onPropertyCreated?.();
|
||||
onSuccess: (created) => {
|
||||
onPropertyCreated?.(created);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -293,7 +293,7 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
|
||||
astVersion: 1,
|
||||
} as TypeOptions,
|
||||
},
|
||||
{ onSuccess: () => onPropertyCreated?.() },
|
||||
{ onSuccess: (created) => onPropertyCreated?.(created) },
|
||||
);
|
||||
handleClose();
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Popover } from "@mantine/core";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
@@ -17,6 +17,8 @@ type PropertyRowProps = {
|
||||
onMenuOpenChange: (opened: boolean) => void;
|
||||
onMenuDirtyChange: (dirty: boolean) => void;
|
||||
onUpdate: (propertyId: string, value: unknown) => void;
|
||||
autoFocusValue?: boolean;
|
||||
onAutoFocused?: () => void;
|
||||
};
|
||||
|
||||
export function PropertyRow({
|
||||
@@ -27,8 +29,23 @@ export function PropertyRow({
|
||||
onMenuOpenChange,
|
||||
onMenuDirtyChange,
|
||||
onUpdate,
|
||||
autoFocusValue,
|
||||
onAutoFocused,
|
||||
}: PropertyRowProps) {
|
||||
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(() => {
|
||||
onMenuOpenChange(!menuOpened);
|
||||
@@ -48,7 +65,7 @@ export function PropertyRow({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classes.propertyRow}>
|
||||
<div className={classes.propertyRow} ref={rowRef}>
|
||||
{canEdit ? (
|
||||
<Popover
|
||||
opened={menuOpened}
|
||||
|
||||
@@ -80,6 +80,8 @@ export function RowDetailModal({
|
||||
// The shared closeRequest atom asks an open dirty PropertyMenuContent to
|
||||
// run its discard-confirm flow instead of being torn down mid-edit.
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||
const [newPropertyId, setNewPropertyId] = useState<string | null>(null);
|
||||
const clearNewProperty = useCallback(() => setNewPropertyId(null), []);
|
||||
const menuDirtyRef = useRef(false);
|
||||
const [closeRequest, setCloseRequest] = useAtom(
|
||||
propertyMenuCloseRequestAtomFamily(base.id),
|
||||
@@ -310,6 +312,8 @@ export function RowDetailModal({
|
||||
property={property}
|
||||
row={row}
|
||||
pageId={base.id}
|
||||
autoFocusValue={property.id === newPropertyId}
|
||||
onAutoFocused={clearNewProperty}
|
||||
menuOpened={openMenuId === property.id}
|
||||
onMenuOpenChange={(nextOpened) =>
|
||||
handleMenuOpenChange(property.id, nextOpened)
|
||||
@@ -329,6 +333,7 @@ export function RowDetailModal({
|
||||
<CreatePropertyPopover
|
||||
pageId={base.id}
|
||||
properties={base.properties}
|
||||
onPropertyCreated={(p) => setNewPropertyId(p.id)}
|
||||
renderTarget={(open) => (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
|
||||
import { timeAgo } from "@/lib/time.ts";
|
||||
@@ -22,18 +22,27 @@ export function RowDetailTitle({
|
||||
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
|
||||
: "";
|
||||
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).
|
||||
useEffect(() => {
|
||||
setValue(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)) : "";
|
||||
|
||||
return (
|
||||
<header className={classes.header}>
|
||||
{canEdit ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={classes.titleInput}
|
||||
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,
|
||||
IBaseRow,
|
||||
IBaseView,
|
||||
FilterGroup,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { BaseTable } from "@/ee/base/components/base-table";
|
||||
import { BaseKanban } from "@/ee/base/components/kanban/base-kanban";
|
||||
|
||||
type ViewRendererProps = {
|
||||
base: IBase;
|
||||
@@ -13,6 +15,7 @@ type ViewRendererProps = {
|
||||
table: Table<IBaseRow>;
|
||||
pageId: string;
|
||||
embedded?: boolean;
|
||||
editable: boolean;
|
||||
isFiltered: boolean;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
@@ -29,15 +32,28 @@ type ViewRendererProps = {
|
||||
persistViewConfig: () => void;
|
||||
scrollportRef: React.RefObject<HTMLDivElement>;
|
||||
aboveBand?: React.ReactNode;
|
||||
kanbanFilter?: FilterGroup | undefined;
|
||||
};
|
||||
|
||||
export function ViewRenderer(props: ViewRendererProps) {
|
||||
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") {
|
||||
return <BaseTable {...props} />;
|
||||
}
|
||||
|
||||
// Kanban not yet implemented; fall back to table to avoid blank page.
|
||||
return <BaseTable {...props} />;
|
||||
}
|
||||
|
||||
@@ -10,19 +10,17 @@ import {
|
||||
Group,
|
||||
UnstyledButton,
|
||||
Text,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
TextInput,
|
||||
Popover,
|
||||
Stack,
|
||||
Divider,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconPlus,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
IconTable,
|
||||
IconLink,
|
||||
IconLayoutKanban,
|
||||
} from "@tabler/icons-react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
@@ -36,7 +34,8 @@ import {
|
||||
type Edge,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
||||
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 {
|
||||
useUpdateViewMutation,
|
||||
useDeleteViewMutation,
|
||||
@@ -54,6 +53,8 @@ type ViewTabsProps = {
|
||||
pageId: string;
|
||||
onViewChange: (viewId: string) => void;
|
||||
onAddView?: () => void;
|
||||
base?: IBase;
|
||||
canAddView?: boolean;
|
||||
/** Standalone base-page link for a view, used by "Copy link to view". */
|
||||
getViewShareUrl?: (viewId: string) => string | null;
|
||||
};
|
||||
@@ -64,6 +65,8 @@ export function ViewTabs({
|
||||
pageId,
|
||||
onViewChange,
|
||||
onAddView,
|
||||
base,
|
||||
canAddView,
|
||||
getViewShareUrl,
|
||||
}: ViewTabsProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -174,7 +177,6 @@ export function ViewTabs({
|
||||
isEditing={view.id === editingViewId}
|
||||
editingName={editingName}
|
||||
canDelete={orderedViews.length > 1}
|
||||
multipleViews={orderedViews.length > 1}
|
||||
reorderEnabled={editable && orderedViews.length > 1}
|
||||
onReorder={handleReorder}
|
||||
onClick={() => onViewChange(view.id)}
|
||||
@@ -186,17 +188,8 @@ export function ViewTabs({
|
||||
getViewShareUrl={getViewShareUrl}
|
||||
/>
|
||||
))}
|
||||
{onAddView && (
|
||||
<Tooltip label={t("Add view")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
onClick={onAddView}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{canAddView && base && (
|
||||
<ViewCreateMenu base={base} pageId={pageId} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
@@ -208,7 +201,6 @@ function ViewTab({
|
||||
isEditing,
|
||||
editingName,
|
||||
canDelete,
|
||||
multipleViews,
|
||||
reorderEnabled,
|
||||
onReorder,
|
||||
onClick,
|
||||
@@ -224,7 +216,6 @@ function ViewTab({
|
||||
isEditing: boolean;
|
||||
editingName: string;
|
||||
canDelete: boolean;
|
||||
multipleViews: boolean;
|
||||
reorderEnabled: boolean;
|
||||
onReorder: (sourceId: string, targetId: string, edge: Edge) => void;
|
||||
onClick: () => void;
|
||||
@@ -336,14 +327,17 @@ function ViewTab({
|
||||
padding: "2px 10px",
|
||||
borderRadius: "var(--mantine-radius-xl)",
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
backgroundColor:
|
||||
isActive && multipleViews
|
||||
? "light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5))"
|
||||
: undefined,
|
||||
backgroundColor: isActive
|
||||
? "light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5))"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<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"}>
|
||||
{view.name}
|
||||
</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 { formulaRecomputeAtom } from "@/ee/base/atoms/formula-recompute-atom";
|
||||
import { IPagination } from "@/lib/types";
|
||||
import { invalidateKanbanColumns } from "@/ee/base/queries/base-row-query";
|
||||
|
||||
type BaseRowCreated = {
|
||||
operation: "base:row:created";
|
||||
@@ -161,45 +162,57 @@ export function useBaseSocket(pageId: string | undefined): void {
|
||||
switch (event.operation) {
|
||||
case "base:row:created": {
|
||||
const e = event as BaseRowCreated;
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", pageId] },
|
||||
(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, e.row] }
|
||||
: page,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
const baseForCreate = queryClient.getQueryData<IBase>(["bases", pageId]);
|
||||
const hasKanbanForCreate = (baseForCreate?.views ?? []).some((v) => v.type === "kanban");
|
||||
if (hasKanbanForCreate) {
|
||||
invalidateKanbanColumns(pageId);
|
||||
} else {
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", pageId] },
|
||||
(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, e.row] }
|
||||
: page,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "base:row:updated": {
|
||||
const e = event as BaseRowUpdated;
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", pageId] },
|
||||
(old) =>
|
||||
!old
|
||||
? old
|
||||
: {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((row) =>
|
||||
row.id === e.rowId
|
||||
? {
|
||||
...row,
|
||||
cells: { ...row.cells, ...e.updatedCells },
|
||||
}
|
||||
: row,
|
||||
),
|
||||
})),
|
||||
},
|
||||
);
|
||||
const baseForUpdate = queryClient.getQueryData<IBase>(["bases", pageId]);
|
||||
const hasKanbanForUpdate = (baseForUpdate?.views ?? []).some((v) => v.type === "kanban");
|
||||
if (hasKanbanForUpdate) {
|
||||
invalidateKanbanColumns(pageId);
|
||||
} else {
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", pageId] },
|
||||
(old) =>
|
||||
!old
|
||||
? old
|
||||
: {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((row) =>
|
||||
row.id === e.rowId
|
||||
? {
|
||||
...row,
|
||||
cells: { ...row.cells, ...e.updatedCells },
|
||||
}
|
||||
: row,
|
||||
),
|
||||
})),
|
||||
},
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "base:row:deleted": {
|
||||
@@ -258,23 +271,29 @@ export function useBaseSocket(pageId: string | undefined): void {
|
||||
}
|
||||
case "base:row:reordered": {
|
||||
const e = event as BaseRowReordered;
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", pageId] },
|
||||
(old) =>
|
||||
!old
|
||||
? old
|
||||
: {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((row) =>
|
||||
row.id === e.rowId
|
||||
? { ...row, position: e.position }
|
||||
: row,
|
||||
),
|
||||
})),
|
||||
},
|
||||
);
|
||||
const baseForReorder = queryClient.getQueryData<IBase>(["bases", pageId]);
|
||||
const hasKanbanForReorder = (baseForReorder?.views ?? []).some((v) => v.type === "kanban");
|
||||
if (hasKanbanForReorder) {
|
||||
invalidateKanbanColumns(pageId);
|
||||
} else {
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", pageId] },
|
||||
(old) =>
|
||||
!old
|
||||
? old
|
||||
: {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((row) =>
|
||||
row.id === e.rowId
|
||||
? { ...row, position: e.position }
|
||||
: row,
|
||||
),
|
||||
})),
|
||||
},
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
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 [socket] = useAtom(socketAtom);
|
||||
|
||||
return useMutation<IBase, Error, { pageId: string }>({
|
||||
mutationFn: ({ pageId }) => convertPageToBase(pageId),
|
||||
return useMutation<IBase, Error, { pageId: string; template?: "kanban" }>({
|
||||
mutationFn: ({ pageId, template }) => convertPageToBase(pageId, template),
|
||||
onSuccess: (base) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["pages"] });
|
||||
queryClient.invalidateQueries({
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
listRows,
|
||||
reorderRow,
|
||||
countRows,
|
||||
groupCountRows,
|
||||
IBaseRowsPage,
|
||||
} from "@/ee/base/services/base-service";
|
||||
import {
|
||||
IBase,
|
||||
IBaseRow,
|
||||
CreateRowInput,
|
||||
UpdateRowInput,
|
||||
@@ -27,7 +29,9 @@ import {
|
||||
SearchSpec,
|
||||
ViewSortConfig,
|
||||
CountRowsResult,
|
||||
GroupCountsResult,
|
||||
RowReferences,
|
||||
NO_VALUE_CHOICE_ID,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
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).
|
||||
// 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 ('children' in filter && filter.children.length === 0) return undefined;
|
||||
return filter;
|
||||
@@ -55,6 +59,61 @@ function newRequestId(): string {
|
||||
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(
|
||||
pageId: string | undefined,
|
||||
filter?: FilterNode,
|
||||
@@ -66,7 +125,7 @@ export function useBaseRowsQuery(
|
||||
const activeSearch = search?.query ? search : undefined;
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ["base-rows", pageId, activeFilter, activeSorts, activeSearch],
|
||||
queryKey: baseRowsQueryKey(pageId, filter, sorts, search),
|
||||
queryFn: ({ pageParam }) =>
|
||||
listRows(pageId!, {
|
||||
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: () => {
|
||||
notifications.show({
|
||||
@@ -209,7 +272,7 @@ export function useUpdateRowMutation() {
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
onSuccess: (updatedRow) => {
|
||||
onSuccess: (updatedRow, variables) => {
|
||||
queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>(
|
||||
{ queryKey: ["base-rows", updatedRow.pageId] },
|
||||
(old) => {
|
||||
@@ -234,6 +297,18 @@ export function useUpdateRowMutation() {
|
||||
? { ...old, ...updatedRow, cells: { ...old.cells, ...updatedRow.cells } }
|
||||
: 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;
|
||||
|
||||
return useQuery<CountRowsResult>({
|
||||
queryKey: ["base-rows-count", pageId, activeFilter, activeSearch],
|
||||
queryKey: baseRowsCountKey(pageId, filter, search),
|
||||
queryFn: () =>
|
||||
countRows({
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
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,
|
||||
CountRowsInput,
|
||||
CountRowsResult,
|
||||
GroupCountsInput,
|
||||
GroupCountsResult,
|
||||
RowReferences,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { IPagination } from "@/lib/types";
|
||||
@@ -33,8 +35,6 @@ export type IBaseRowsPage = IPagination<IBaseRow> & {
|
||||
references?: RowReferences;
|
||||
};
|
||||
|
||||
// --- Bases ---
|
||||
|
||||
export async function createBase(data: CreateBaseInput): Promise<IBase> {
|
||||
const req = await api.post<IBase>("/bases/create", data);
|
||||
return req.data;
|
||||
@@ -54,8 +54,11 @@ export async function deleteBase(pageId: string): Promise<void> {
|
||||
await api.post("/bases/delete", { pageId });
|
||||
}
|
||||
|
||||
export async function convertPageToBase(pageId: string): Promise<IBase> {
|
||||
const req = await api.post<IBase>("/bases/convert", { pageId });
|
||||
export async function convertPageToBase(
|
||||
pageId: string,
|
||||
template?: "kanban",
|
||||
): Promise<IBase> {
|
||||
const req = await api.post<IBase>("/bases/convert", { pageId, template });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@@ -87,8 +90,6 @@ export async function listBases(
|
||||
return req.data;
|
||||
}
|
||||
|
||||
// --- Properties ---
|
||||
|
||||
export async function createProperty(
|
||||
data: CreatePropertyInput,
|
||||
): Promise<IBaseProperty> {
|
||||
@@ -116,8 +117,6 @@ export async function reorderProperty(
|
||||
await api.post("/bases/properties/reorder", data);
|
||||
}
|
||||
|
||||
// --- Rows ---
|
||||
|
||||
export async function createRow(data: CreateRowInput): Promise<IBaseRow> {
|
||||
const req = await api.post<IBaseRow>("/bases/rows/create", data);
|
||||
return req.data;
|
||||
@@ -169,6 +168,16 @@ export async function countRows(
|
||||
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 ---
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* ---------- 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. */
|
||||
|
||||
.fieldShell {
|
||||
|
||||
@@ -25,6 +25,8 @@ export type BasePropertyType =
|
||||
| 'formula'
|
||||
| 'longText';
|
||||
|
||||
export type BaseViewType = 'table' | 'kanban' | 'calendar';
|
||||
|
||||
export type Choice = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -193,6 +195,10 @@ export type SearchSpec = {
|
||||
|
||||
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 = {
|
||||
sorts?: ViewSortConfig[];
|
||||
filter?: FilterGroup;
|
||||
@@ -214,7 +220,7 @@ export type IBaseView = {
|
||||
id: string;
|
||||
pageId: string;
|
||||
name: string;
|
||||
type: 'table' | 'kanban' | 'calendar';
|
||||
type: BaseViewType;
|
||||
config: ViewConfig;
|
||||
position: string;
|
||||
workspaceId: string;
|
||||
@@ -301,10 +307,22 @@ export type CountRowsResult = {
|
||||
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 = {
|
||||
pageId: string;
|
||||
cells?: Record<string, unknown>;
|
||||
afterRowId?: string;
|
||||
position?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
@@ -338,7 +356,7 @@ export type ReorderRowInput = {
|
||||
export type CreateViewInput = {
|
||||
pageId: string;
|
||||
name: string;
|
||||
type?: 'table' | 'kanban' | 'calendar';
|
||||
type?: BaseViewType;
|
||||
config?: ViewConfig;
|
||||
};
|
||||
|
||||
@@ -346,7 +364,7 @@ export type UpdateViewInput = {
|
||||
viewId: string;
|
||||
pageId: string;
|
||||
name?: string;
|
||||
type?: 'table' | 'kanban' | 'calendar';
|
||||
type?: BaseViewType;
|
||||
config?: ViewConfigPatch;
|
||||
position?: string;
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
|
||||
|
||||
export const yjsConnectionStatusAtom = atom<string>("");
|
||||
|
||||
export const yjsSyncedAtom = atom<boolean>(false);
|
||||
|
||||
export const showAiMenuAtom = 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 { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
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;
|
||||
|
||||
@@ -56,6 +59,7 @@ export function BaseEmbedView({ node, editor }: NodeViewProps) {
|
||||
const { data: base, isLoading, isError } = useBaseQuery(
|
||||
pendingKey ? "" : pageId ?? "",
|
||||
);
|
||||
const { data: page } = usePageQuery({ pageId: pageId ?? undefined });
|
||||
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
@@ -129,7 +133,11 @@ export function BaseEmbedView({ node, editor }: NodeViewProps) {
|
||||
}
|
||||
|
||||
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 }}>
|
||||
{content}
|
||||
</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 { Button } from "@mantine/core";
|
||||
import { IconTable } from "@tabler/icons-react";
|
||||
import { IconTable, IconLayoutKanban } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtomValue } from "jotai";
|
||||
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 { Feature } from "@/ee/features";
|
||||
import classes from "./empty-page-get-started.module.css";
|
||||
@@ -20,6 +23,7 @@ export function EmptyPageGetStarted({
|
||||
}: EmptyPageGetStartedProps) {
|
||||
const { t } = useTranslation();
|
||||
const editor = useAtomValue(pageEditorAtom);
|
||||
const isSynced = useAtomValue(yjsSyncedAtom);
|
||||
const hasBases = useHasFeature(Feature.BASES);
|
||||
const convertMutation = useConvertPageToBaseMutation();
|
||||
|
||||
@@ -36,7 +40,7 @@ export function EmptyPageGetStarted({
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
if (!editable || !hasBases || !editor || !isEmpty) return null;
|
||||
if (!editable || !hasBases || !editor || !isSynced || !isEmpty) return null;
|
||||
|
||||
const chips = [
|
||||
{
|
||||
@@ -46,6 +50,13 @@ export function EmptyPageGetStarted({
|
||||
onClick: () => convertMutation.mutate({ pageId }),
|
||||
disabled: convertMutation.isPending,
|
||||
},
|
||||
{
|
||||
key: "kanban",
|
||||
label: t("Kanban"),
|
||||
icon: IconLayoutKanban,
|
||||
onClick: () => convertMutation.mutate({ pageId, template: "kanban" }),
|
||||
disabled: convertMutation.isPending,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IconH2,
|
||||
IconH3,
|
||||
IconInfoCircle,
|
||||
IconLayoutKanban,
|
||||
IconList,
|
||||
IconListNumbers,
|
||||
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",
|
||||
description: "Insert collapsible block.",
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface GlobalDragHandleOptions {
|
||||
* Custom nodes to be included for drag handle
|
||||
*/
|
||||
customNodes: string[];
|
||||
|
||||
atomNodes: string[];
|
||||
}
|
||||
function absoluteRect(node: Element) {
|
||||
const data = node.getBoundingClientRect();
|
||||
@@ -76,6 +78,10 @@ function nodeDOMAtCoords(
|
||||
`[data-type=${node}] p`,
|
||||
`.node-${node} p`,
|
||||
]);
|
||||
const atomSelectors = options.atomNodes.flatMap((node) => [
|
||||
`[data-type=${node}]`,
|
||||
`.node-${node}`,
|
||||
]);
|
||||
|
||||
const selectors = [
|
||||
"li",
|
||||
@@ -95,8 +101,9 @@ function nodeDOMAtCoords(
|
||||
".tableWrapper",
|
||||
...customParagraphSelectors,
|
||||
...customSelectors,
|
||||
...atomSelectors,
|
||||
].join(", ");
|
||||
return document
|
||||
const found = document
|
||||
.elementsFromPoint(coords.x, coords.y)
|
||||
.find((elem: Element) => {
|
||||
// Skip elements that belong to a nested editor (e.g. transclusion
|
||||
@@ -108,6 +115,11 @@ function nodeDOMAtCoords(
|
||||
elem.matches(selectors)
|
||||
);
|
||||
});
|
||||
if (found && atomSelectors.length > 0) {
|
||||
const atomWrapper = found.closest(atomSelectors.join(", "));
|
||||
if (atomWrapper) return atomWrapper;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
function nodePosAtDOM(
|
||||
node: Element,
|
||||
@@ -127,7 +139,7 @@ function isCustomNodeDOM(
|
||||
options: GlobalDragHandleOptions,
|
||||
): boolean {
|
||||
if (!elem) return false;
|
||||
for (const name of options.customNodes) {
|
||||
for (const name of [...options.customNodes, ...options.atomNodes]) {
|
||||
if (
|
||||
elem.getAttribute("data-type") === name ||
|
||||
elem.classList.contains(`node-${name}`)
|
||||
@@ -210,7 +222,10 @@ export function DragHandlePlugin(
|
||||
// The drag landed on a custom-node container (transclusion etc.).
|
||||
// Walk up to the matching node so the drag moves the whole
|
||||
// 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--) {
|
||||
if (customTypes.has($sel.node(d).type.name)) {
|
||||
selection = NodeSelection.create(
|
||||
@@ -264,7 +279,23 @@ export function DragHandlePlugin(
|
||||
event.dataTransfer.setData("text/plain", text);
|
||||
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 };
|
||||
}
|
||||
@@ -497,6 +528,7 @@ const GlobalDragHandle = Extension.create({
|
||||
scrollThreshold: 100,
|
||||
excludedTags: [],
|
||||
customNodes: [],
|
||||
atomNodes: [],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -509,6 +541,7 @@ const GlobalDragHandle = Extension.create({
|
||||
dragHandleSelector: this.options.dragHandleSelector,
|
||||
excludedTags: this.options.excludedTags,
|
||||
customNodes: this.options.customNodes,
|
||||
atomNodes: this.options.atomNodes,
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
@@ -233,6 +233,7 @@ export const mainExtensions = [
|
||||
TrailingNode,
|
||||
GlobalDragHandle.configure({
|
||||
customNodes: ["transclusionSource", "transclusionReference"],
|
||||
atomNodes: ["base"],
|
||||
}),
|
||||
TextStyle,
|
||||
Color,
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
currentPageEditModeAtom,
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
yjsSyncedAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||
import {
|
||||
@@ -109,6 +110,7 @@ export default function PageEditor({
|
||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||
yjsConnectionStatusAtom,
|
||||
);
|
||||
const [, setYjsSynced] = useAtom(yjsSyncedAtom);
|
||||
const menuContainerRef = useRef(null);
|
||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||
@@ -378,6 +380,14 @@ export default function PageEditor({
|
||||
|
||||
const isSynced = isLocalSynced && isRemoteSynced;
|
||||
|
||||
useEffect(() => {
|
||||
setYjsSynced(isSynced);
|
||||
}, [isSynced, setYjsSynced]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => setYjsSynced(false);
|
||||
}, [setYjsSynced]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
|
||||
|
||||
@@ -87,8 +87,6 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ---- pragmatic-tree additions ---- */
|
||||
|
||||
.rowWrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -107,7 +107,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||
// Height: see `.base-page-root` in core.css.
|
||||
// Clear the fixed PageHeader (breadcrumb) plus a little extra so the
|
||||
// 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>
|
||||
@@ -137,7 +137,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||
titleSlot={
|
||||
<div
|
||||
className="base-page-title"
|
||||
style={{ paddingTop: 32, paddingBottom: 12 }}
|
||||
style={{ paddingTop: 2, paddingBottom: 6 }}
|
||||
>
|
||||
<MemoizedTitleEditor
|
||||
pageId={page.id}
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"@aws-sdk/s3-request-presigner": "3.1050.0",
|
||||
"@azure/storage-blob": "12.31.0",
|
||||
"@clickhouse/client": "^1.18.2",
|
||||
"@docmost/base-formula": "workspace:*",
|
||||
"@docmost/pdf-inspector": "1.9.6",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 73a853e237...c89c87e4f4
Generated
+3
@@ -513,6 +513,9 @@ importers:
|
||||
'@clickhouse/client':
|
||||
specifier: ^1.18.2
|
||||
version: 1.18.2
|
||||
'@docmost/base-formula':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/base-formula
|
||||
'@docmost/pdf-inspector':
|
||||
specifier: 1.9.6
|
||||
version: 1.9.6
|
||||
|
||||
Reference in New Issue
Block a user