mirror of
https://github.com/docmost/docmost.git
synced 2026-05-20 00:14:10 +08:00
Base WIP
This commit is contained in:
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||
import { useBaseQuery } from "@/features/base/queries/base-query";
|
||||
import { useBaseSocket } from "@/features/base/hooks/use-base-socket";
|
||||
import {
|
||||
useBaseRowsQuery,
|
||||
flattenRows,
|
||||
@@ -26,6 +27,9 @@ type BaseTableProps = {
|
||||
|
||||
export function BaseTable({ baseId }: BaseTableProps) {
|
||||
const { t } = useTranslation();
|
||||
// Subscribe to the base's realtime room so other clients' edits,
|
||||
// schema changes, and async-job completions reconcile into our cache.
|
||||
useBaseSocket(baseId);
|
||||
const { data: base, isLoading: baseLoading, error: baseError } = useBaseQuery(baseId);
|
||||
|
||||
const [activeViewId, setActiveViewId] = useAtom(activeViewIdAtom) as unknown as [string | null, (val: string | null) => void];
|
||||
@@ -36,10 +40,10 @@ export function BaseTable({ baseId }: BaseTableProps) {
|
||||
return views.find((v) => v.id === activeViewId) ?? views[0];
|
||||
}, [views, activeViewId]);
|
||||
|
||||
const activeFilters = activeView?.config?.filters;
|
||||
const activeFilter = activeView?.config?.filter;
|
||||
const activeSorts = activeView?.config?.sorts;
|
||||
const { data: rowsData, isLoading: rowsLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useBaseRowsQuery(baseId, activeFilters, activeSorts);
|
||||
useBaseRowsQuery(baseId, activeFilter, activeSorts);
|
||||
|
||||
const updateRowMutation = useUpdateRowMutation();
|
||||
const createRowMutation = useCreateRowMutation();
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
IBaseRow,
|
||||
IBaseView,
|
||||
ViewSortConfig,
|
||||
ViewFilterConfig,
|
||||
FilterCondition,
|
||||
FilterGroup,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { useUpdateViewMutation } from "@/features/base/queries/base-view-query";
|
||||
import { ViewTabs } from "@/features/base/components/views/view-tabs";
|
||||
@@ -54,7 +55,16 @@ export function BaseToolbar({
|
||||
const updateViewMutation = useUpdateViewMutation();
|
||||
|
||||
const sorts = activeView?.config?.sorts ?? [];
|
||||
const filters = activeView?.config?.filters ?? [];
|
||||
// Stored view config uses the engine's filter tree. The popover edits
|
||||
// an AND-only flat list; we unwrap the top-level group's children when
|
||||
// reading and rewrap on save.
|
||||
const conditions = useMemo<FilterCondition[]>(() => {
|
||||
const filter = activeView?.config?.filter;
|
||||
if (!filter || filter.op !== "and") return [];
|
||||
return filter.children.filter(
|
||||
(c): c is FilterCondition => !("children" in c),
|
||||
);
|
||||
}, [activeView?.config?.filter]);
|
||||
|
||||
const hiddenFieldCount = useMemo(() => {
|
||||
const cols = table.getAllLeafColumns().filter((col) => col.id !== "__row_number");
|
||||
@@ -74,12 +84,17 @@ export function BaseToolbar({
|
||||
);
|
||||
|
||||
const handleFiltersChange = useCallback(
|
||||
(newFilters: ViewFilterConfig[]) => {
|
||||
(newConditions: FilterCondition[]) => {
|
||||
if (!activeView) return;
|
||||
const filter: FilterGroup | undefined =
|
||||
newConditions.length > 0
|
||||
? { op: "and", children: newConditions }
|
||||
: undefined;
|
||||
const { filter: _drop, ...rest } = activeView.config ?? {};
|
||||
updateViewMutation.mutate({
|
||||
viewId: activeView.id,
|
||||
baseId: base.id,
|
||||
config: { ...activeView.config, filters: newFilters },
|
||||
config: filter ? { ...rest, filter } : rest,
|
||||
});
|
||||
},
|
||||
[activeView, base.id, updateViewMutation],
|
||||
@@ -99,7 +114,7 @@ export function BaseToolbar({
|
||||
<ViewFilterConfigPopover
|
||||
opened={filterOpened}
|
||||
onClose={() => setFilterOpened(false)}
|
||||
filters={filters}
|
||||
conditions={conditions}
|
||||
properties={base.properties}
|
||||
onChange={handleFiltersChange}
|
||||
>
|
||||
@@ -107,11 +122,11 @@ export function BaseToolbar({
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color={filters.length > 0 ? "blue" : "gray"}
|
||||
color={conditions.length > 0 ? "blue" : "gray"}
|
||||
onClick={() => openToolbar("filter")}
|
||||
>
|
||||
<IconFilter size={16} />
|
||||
{filters.length > 0 && (
|
||||
{conditions.length > 0 && (
|
||||
<Badge
|
||||
size="xs"
|
||||
circle
|
||||
@@ -127,7 +142,7 @@ export function BaseToolbar({
|
||||
fontSize: 9,
|
||||
}}
|
||||
>
|
||||
{filters.length}
|
||||
{conditions.length}
|
||||
</Badge>
|
||||
)}
|
||||
</ActionIcon>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { memo, useCallback, useRef } from "react";
|
||||
import { Header, flexRender } from "@tanstack/react-table";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Popover } from "@mantine/core";
|
||||
import { Loader, Popover, Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types";
|
||||
import { activePropertyMenuAtom, propertyMenuDirtyAtom, editingCellAtom } from "@/features/base/atoms/base-atoms";
|
||||
@@ -49,12 +50,14 @@ type GridHeaderCellProps = {
|
||||
export const GridHeaderCell = memo(function GridHeaderCell({
|
||||
header,
|
||||
}: GridHeaderCellProps) {
|
||||
const { t } = useTranslation();
|
||||
const property = header.column.columnDef.meta?.property as
|
||||
| IBaseProperty
|
||||
| undefined;
|
||||
const isRowNumber = header.column.id === "__row_number";
|
||||
const isPinned = header.column.getIsPinned();
|
||||
const pinOffset = isPinned ? header.column.getStart("left") : undefined;
|
||||
const isConverting = !!property?.pendingType;
|
||||
|
||||
const [activePropertyMenu, setActivePropertyMenu] = useAtom(activePropertyMenuAtom) as unknown as [string | null, (val: string | null) => void];
|
||||
const menuOpened = activePropertyMenu === header.column.id;
|
||||
@@ -138,6 +141,20 @@ export const GridHeaderCell = memo(function GridHeaderCell({
|
||||
<span className={classes.headerCellName}>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</span>
|
||||
{isConverting && (
|
||||
<Tooltip
|
||||
label={t("Converting to {{type}}…", {
|
||||
type: property?.pendingType,
|
||||
})}
|
||||
withArrow
|
||||
>
|
||||
<Loader
|
||||
size={12}
|
||||
color="gray"
|
||||
className={classes.headerConvertingSpinner}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{header.column.getCanResize() && (
|
||||
|
||||
@@ -13,61 +13,69 @@ import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import {
|
||||
IBaseProperty,
|
||||
SelectTypeOptions,
|
||||
ViewFilterConfig,
|
||||
ViewFilterOperator,
|
||||
FilterCondition,
|
||||
FilterOperator,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const OPERATORS: { value: ViewFilterOperator; labelKey: string }[] = [
|
||||
{ value: "equals", labelKey: "Equals" },
|
||||
{ value: "notEquals", labelKey: "Not equals" },
|
||||
/*
|
||||
* Operator metadata for the filter popover. Values use the server
|
||||
* engine's operator set (`core/base/engine/schema.zod.ts`); labels are
|
||||
* i18n-translated display strings.
|
||||
*/
|
||||
const OPERATORS: { value: FilterOperator; labelKey: string }[] = [
|
||||
{ value: "eq", labelKey: "Equals" },
|
||||
{ value: "neq", labelKey: "Not equals" },
|
||||
{ value: "contains", labelKey: "Contains" },
|
||||
{ value: "notContains", labelKey: "Not contains" },
|
||||
{ value: "ncontains", labelKey: "Not contains" },
|
||||
{ value: "isEmpty", labelKey: "Is empty" },
|
||||
{ value: "isNotEmpty", labelKey: "Is not empty" },
|
||||
{ value: "greaterThan", labelKey: "Greater than" },
|
||||
{ value: "lessThan", labelKey: "Less than" },
|
||||
{ value: "gt", labelKey: "Greater than" },
|
||||
{ value: "lt", labelKey: "Less than" },
|
||||
{ value: "before", labelKey: "Before" },
|
||||
{ value: "after", labelKey: "After" },
|
||||
{ value: "any", labelKey: "Any of" },
|
||||
{ value: "none", labelKey: "None of" },
|
||||
];
|
||||
|
||||
const NO_VALUE_OPERATORS: ViewFilterOperator[] = ["isEmpty", "isNotEmpty"];
|
||||
const NO_VALUE_OPERATORS: FilterOperator[] = ["isEmpty", "isNotEmpty"];
|
||||
|
||||
function getOperatorsForType(type: string): ViewFilterOperator[] {
|
||||
function getOperatorsForType(type: string): FilterOperator[] {
|
||||
switch (type) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "url":
|
||||
return ["equals", "notEquals", "contains", "notContains", "isEmpty", "isNotEmpty"];
|
||||
return ["eq", "neq", "contains", "ncontains", "isEmpty", "isNotEmpty"];
|
||||
case "number":
|
||||
return ["equals", "notEquals", "greaterThan", "lessThan", "isEmpty", "isNotEmpty"];
|
||||
return ["eq", "neq", "gt", "lt", "isEmpty", "isNotEmpty"];
|
||||
case "date":
|
||||
case "createdAt":
|
||||
case "lastEditedAt":
|
||||
return ["equals", "notEquals", "before", "after", "isEmpty", "isNotEmpty"];
|
||||
return ["eq", "neq", "before", "after", "isEmpty", "isNotEmpty"];
|
||||
case "select":
|
||||
case "status":
|
||||
return ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"];
|
||||
case "multiSelect":
|
||||
return ["equals", "notEquals", "isEmpty", "isNotEmpty"];
|
||||
return ["any", "none", "isEmpty", "isNotEmpty"];
|
||||
case "checkbox":
|
||||
return ["equals", "isEmpty", "isNotEmpty"];
|
||||
return ["eq", "isEmpty", "isNotEmpty"];
|
||||
case "person":
|
||||
case "lastEditedBy":
|
||||
return ["equals", "notEquals", "isEmpty", "isNotEmpty"];
|
||||
return ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"];
|
||||
case "file":
|
||||
return ["isEmpty", "isNotEmpty"];
|
||||
default:
|
||||
return ["equals", "notEquals", "isEmpty", "isNotEmpty"];
|
||||
return ["eq", "neq", "isEmpty", "isNotEmpty"];
|
||||
}
|
||||
}
|
||||
|
||||
function FilterValueInput({
|
||||
filter,
|
||||
condition,
|
||||
property,
|
||||
onChange,
|
||||
t,
|
||||
}: {
|
||||
filter: ViewFilterConfig;
|
||||
condition: FilterCondition;
|
||||
property: IBaseProperty | undefined;
|
||||
onChange: (value: string) => void;
|
||||
t: (key: string) => string;
|
||||
@@ -77,7 +85,7 @@ function FilterValueInput({
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder={t("Value")}
|
||||
value={(filter.value as string) ?? ""}
|
||||
value={(condition.value as string) ?? ""}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
w={100}
|
||||
/>
|
||||
@@ -94,7 +102,7 @@ function FilterValueInput({
|
||||
<Select
|
||||
size="xs"
|
||||
data={choiceOptions}
|
||||
value={(filter.value as string) ?? null}
|
||||
value={(condition.value as string) ?? null}
|
||||
onChange={(val) => onChange(val ?? "")}
|
||||
w={120}
|
||||
placeholder={t("Select")}
|
||||
@@ -108,7 +116,7 @@ function FilterValueInput({
|
||||
size="xs"
|
||||
type="number"
|
||||
placeholder={t("Value")}
|
||||
value={(filter.value as string) ?? ""}
|
||||
value={(condition.value as string) ?? ""}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
w={100}
|
||||
/>
|
||||
@@ -123,7 +131,7 @@ function FilterValueInput({
|
||||
{ value: "true", label: t("True") },
|
||||
{ value: "false", label: t("False") },
|
||||
]}
|
||||
value={(filter.value as string) ?? null}
|
||||
value={(condition.value as string) ?? null}
|
||||
onChange={(val) => onChange(val ?? "")}
|
||||
w={100}
|
||||
/>
|
||||
@@ -134,7 +142,7 @@ function FilterValueInput({
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder={t("Value")}
|
||||
value={(filter.value as string) ?? ""}
|
||||
value={(condition.value as string) ?? ""}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
w={100}
|
||||
/>
|
||||
@@ -144,16 +152,16 @@ function FilterValueInput({
|
||||
type ViewFilterConfigProps = {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
filters: ViewFilterConfig[];
|
||||
conditions: FilterCondition[];
|
||||
properties: IBaseProperty[];
|
||||
onChange: (filters: ViewFilterConfig[]) => void;
|
||||
onChange: (conditions: FilterCondition[]) => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function ViewFilterConfigPopover({
|
||||
opened,
|
||||
onClose,
|
||||
filters,
|
||||
conditions,
|
||||
properties,
|
||||
onChange,
|
||||
children,
|
||||
@@ -169,18 +177,20 @@ export function ViewFilterConfigPopover({
|
||||
const firstProperty = properties[0];
|
||||
if (!firstProperty) return;
|
||||
const validOperators = getOperatorsForType(firstProperty.type);
|
||||
const defaultOperator = validOperators.includes("contains") ? "contains" : validOperators[0];
|
||||
const defaultOperator = validOperators.includes("contains")
|
||||
? ("contains" as FilterOperator)
|
||||
: validOperators[0];
|
||||
onChange([
|
||||
...filters,
|
||||
{ propertyId: firstProperty.id, operator: defaultOperator },
|
||||
...conditions,
|
||||
{ propertyId: firstProperty.id, op: defaultOperator },
|
||||
]);
|
||||
}, [filters, properties, onChange]);
|
||||
}, [conditions, properties, onChange]);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(index: number) => {
|
||||
onChange(filters.filter((_, i) => i !== index));
|
||||
onChange(conditions.filter((_, i) => i !== index));
|
||||
},
|
||||
[filters, onChange],
|
||||
[conditions, onChange],
|
||||
);
|
||||
|
||||
const handlePropertyChange = useCallback(
|
||||
@@ -188,15 +198,15 @@ export function ViewFilterConfigPopover({
|
||||
if (!propertyId) return;
|
||||
const newProperty = properties.find((p) => p.id === propertyId);
|
||||
onChange(
|
||||
filters.map((f, i) => {
|
||||
conditions.map((f, i) => {
|
||||
if (i !== index) return f;
|
||||
if (newProperty) {
|
||||
const validOperators = getOperatorsForType(newProperty.type);
|
||||
const currentOperatorValid = validOperators.includes(f.operator);
|
||||
const currentOperatorValid = validOperators.includes(f.op);
|
||||
return {
|
||||
...f,
|
||||
propertyId,
|
||||
operator: currentOperatorValid ? f.operator : validOperators[0],
|
||||
op: currentOperatorValid ? f.op : validOperators[0],
|
||||
value: currentOperatorValid ? f.value : undefined,
|
||||
};
|
||||
}
|
||||
@@ -204,38 +214,38 @@ export function ViewFilterConfigPopover({
|
||||
}),
|
||||
);
|
||||
},
|
||||
[filters, properties, onChange],
|
||||
[conditions, properties, onChange],
|
||||
);
|
||||
|
||||
const handleOperatorChange = useCallback(
|
||||
(index: number, operator: string | null) => {
|
||||
if (!operator) return;
|
||||
const op = operator as ViewFilterOperator;
|
||||
const op = operator as FilterOperator;
|
||||
const needsValue = !NO_VALUE_OPERATORS.includes(op);
|
||||
onChange(
|
||||
filters.map((f, i) =>
|
||||
conditions.map((f, i) =>
|
||||
i === index
|
||||
? {
|
||||
...f,
|
||||
operator: op,
|
||||
op,
|
||||
value: needsValue ? f.value : undefined,
|
||||
}
|
||||
: f,
|
||||
),
|
||||
);
|
||||
},
|
||||
[filters, onChange],
|
||||
[conditions, onChange],
|
||||
);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(index: number, value: string) => {
|
||||
onChange(
|
||||
filters.map((f, i) =>
|
||||
conditions.map((f, i) =>
|
||||
i === index ? { ...f, value: value || undefined } : f,
|
||||
),
|
||||
);
|
||||
},
|
||||
[filters, onChange],
|
||||
[conditions, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -255,44 +265,46 @@ export function ViewFilterConfigPopover({
|
||||
{t("Filter by")}
|
||||
</Text>
|
||||
|
||||
{filters.length === 0 && (
|
||||
{conditions.length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("No filters applied")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{filters.map((filter, index) => {
|
||||
const needsValue = !NO_VALUE_OPERATORS.includes(filter.operator);
|
||||
const property = properties.find((p) => p.id === filter.propertyId);
|
||||
{conditions.map((condition, index) => {
|
||||
const needsValue = !NO_VALUE_OPERATORS.includes(condition.op);
|
||||
const property = properties.find(
|
||||
(p) => p.id === condition.propertyId,
|
||||
);
|
||||
const validOperators = property
|
||||
? getOperatorsForType(property.type)
|
||||
: OPERATORS.map((op) => op.value);
|
||||
const operatorOptions = OPERATORS
|
||||
.filter((op) => validOperators.includes(op.value))
|
||||
.map((op) => ({
|
||||
value: op.value,
|
||||
label: t(op.labelKey),
|
||||
}));
|
||||
const operatorOptions = OPERATORS.filter((op) =>
|
||||
validOperators.includes(op.value),
|
||||
).map((op) => ({
|
||||
value: op.value,
|
||||
label: t(op.labelKey),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Group key={index} gap="xs" wrap="nowrap">
|
||||
<Select
|
||||
size="xs"
|
||||
data={propertyOptions}
|
||||
value={filter.propertyId}
|
||||
value={condition.propertyId}
|
||||
onChange={(val) => handlePropertyChange(index, val)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
data={operatorOptions}
|
||||
value={filter.operator}
|
||||
value={condition.op}
|
||||
onChange={(val) => handleOperatorChange(index, val)}
|
||||
w={130}
|
||||
/>
|
||||
{needsValue && (
|
||||
<FilterValueInput
|
||||
filter={filter}
|
||||
condition={condition}
|
||||
property={property}
|
||||
onChange={(val) => handleValueChange(index, val)}
|
||||
t={t}
|
||||
|
||||
Reference in New Issue
Block a user