This commit is contained in:
Philipinho
2026-04-18 13:13:53 +01:00
parent 081bb67239
commit f5b19316af
53 changed files with 4057 additions and 813 deletions
@@ -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}