Files
outline/app/scenes/Collection/components/Header.tsx
T
Tom Moor 0139b91b5d chore: Replace lodash with es-toolkit (#12281)
* chore: Replace lodash with es-toolkit

Migrate all direct lodash imports to es-toolkit/compat for a smaller,
faster, lodash-compatible utility library. Transitive lodash usage from
other packages remains unchanged.

* fix: Restore isPlainObject semantics in CanCan policy

The lodash migration aliased `isObject` to `lodash/isPlainObject` and
the codemod incorrectly mapped the local name to es-toolkit's `isObject`,
which also returns true for arrays and functions. This caused condition
objects in policy definitions to be skipped, breaking authorization
checks across the codebase.

* fix: Restore unicode-aware length counting in validators

es-toolkit/compat's size() returns string.length, while lodash's _.size()
counts unicode code points. Switch to [...value].length to preserve the
previous behavior so multi-byte characters like emoji count as one.
2026-05-06 21:03:47 -04:00

99 lines
2.8 KiB
TypeScript

import { IconTitleWrapper } from "@shared/components/Icon";
import breakpoint from "styled-components-breakpoint";
import { first } from "es-toolkit/compat";
import { Suspense, useCallback } from "react";
import styled from "styled-components";
import { CollectionValidation } from "@shared/validations";
import { isRTL } from "@shared/utils/rtl";
import Heading from "~/components/Heading";
import ContentEditable from "~/components/ContentEditable";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import type Collection from "~/models/Collection";
import { colorPalette } from "@shared/utils/collections";
import usePolicy from "~/hooks/usePolicy";
import { observer } from "mobx-react";
import lazyWithRetry from "~/utils/lazyWithRetry";
const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
type Props = {
/** The collection for which to render a header */
collection: Collection;
/** Whether the header is in editing mode */
isEditing?: boolean;
};
export const Header = observer(function Header_({
collection,
isEditing,
}: Props) {
const can = usePolicy(collection);
const canEdit = can.update && isEditing;
const handleIconChange = useCallback(
(icon: string | null, color: string | null) =>
collection?.save({ icon, color }),
[collection]
);
const handleTitleChange = useCallback(
(text: string) => {
const trimmed = text.trim();
if (trimmed.length > 0 && trimmed !== collection.name) {
void collection.save({ name: trimmed });
}
},
[collection]
);
const fallbackIcon = collection ? (
<CollectionIcon collection={collection} size={40} expanded />
) : null;
const dir = isRTL(collection.name) ? "rtl" : "ltr";
return (
<StyledHeading dir={dir}>
<IconTitleWrapper dir={dir}>
{canEdit ? (
<Suspense fallback={fallbackIcon}>
<IconPicker
icon={collection.icon ?? "collection"}
color={collection.color ?? (first(colorPalette) as string)}
initial={collection.initial}
size={40}
popoverPosition="bottom-start"
onChange={handleIconChange}
borderOnHover
>
{fallbackIcon}
</IconPicker>
</Suspense>
) : (
fallbackIcon
)}
</IconTitleWrapper>
{canEdit ? (
<ContentEditable
value={collection.name}
onChange={handleTitleChange}
maxLength={CollectionValidation.maxNameLength}
dir="auto"
/>
) : (
collection.name
)}
</StyledHeading>
);
});
const StyledHeading = styled(Heading)`
display: flex;
align-items: center;
position: relative;
margin-left: 40px;
${breakpoint("tablet")`
margin-left: 0;
`}
`;