Files
IronCalc/webapp/IronCalc/src/components/RightDrawer/NamedRanges/NamedRanges.tsx
2025-12-09 17:15:39 +01:00

588 lines
16 KiB
TypeScript

import type { DefinedName, Model } from "@ironcalc/wasm";
import { Button, styled, Tooltip } from "@mui/material";
import { t } from "i18next";
import {
ArrowLeft,
BookOpen,
PackageOpen,
PencilLine,
Plus,
Trash2,
X,
} from "lucide-react";
import { useState } from "react";
import { theme } from "../../../theme";
import { parseRangeInSheet } from "../../Editor/util";
import EditNamedRange, {
formatOnSaveError,
type SaveError,
} from "./EditNamedRange";
const normalizeRangeString = (range: string): string => {
return range.trim().replace(/['"]/g, "");
};
interface NamedRangesProps {
onClose: () => void;
model: Model;
getSelectedArea: () => string;
onUpdate: () => void;
}
const NamedRanges = ({
onClose,
getSelectedArea,
model,
onUpdate,
}: NamedRangesProps) => {
const [editingDefinedName, setEditingDefinedName] =
useState<DefinedName | null>(null);
const [isCreatingNew, setIsCreatingNew] = useState(false);
const handleListItemClick = (definedName: DefinedName) => {
setEditingDefinedName(definedName);
setIsCreatingNew(false);
};
const handleNewClick = () => {
setIsCreatingNew(true);
setEditingDefinedName(null);
};
const handleCancel = () => {
setEditingDefinedName(null);
setIsCreatingNew(false);
};
const handleSave = (
name: string,
scope: string,
formula: string,
): SaveError => {
const worksheets = model.getWorksheetsProperties();
if (isCreatingNew) {
const scope_index = worksheets.findIndex((s) => s.name === scope);
const newScope = scope_index >= 0 ? scope_index : null;
try {
model.newDefinedName(name, newScope, formula);
setIsCreatingNew(false);
onUpdate();
return {
formulaError: "",
nameError: "",
};
} catch (e) {
if (e instanceof Error) {
return formatOnSaveError(e.message);
}
return { formulaError: "", nameError: `${e}` };
}
} else {
if (!editingDefinedName)
return {
formulaError: "",
nameError: "",
};
const scope_index = worksheets.findIndex((s) => s.name === scope);
const newScope = scope_index >= 0 ? scope_index : null;
try {
model.updateDefinedName(
editingDefinedName.name,
editingDefinedName.scope ?? null,
name,
newScope,
formula,
);
setEditingDefinedName(null);
onUpdate();
return { formulaError: "", nameError: "" };
} catch (e) {
if (e instanceof Error) {
return formatOnSaveError(e.message);
}
return { formulaError: "", nameError: `${e}` };
}
}
};
// Show edit view if a named range is being edited or created
if (editingDefinedName || isCreatingNew) {
let name = "";
let scopeName = "[Global]";
let formula = "";
if (editingDefinedName) {
name = editingDefinedName.name;
const worksheets = model.getWorksheetsProperties();
scopeName =
editingDefinedName.scope != null
? worksheets[editingDefinedName.scope]?.name || "[unknown]"
: "[Global]";
formula = editingDefinedName.formula;
} else if (isCreatingNew) {
formula = getSelectedArea();
}
const headerTitle = isCreatingNew
? t("name_manager_dialog.add_new_range")
: t("name_manager_dialog.edit_range");
return (
<Container>
<EditHeader>
<Tooltip title={t("name_manager_dialog.back_to_list")}>
<IconButtonWrapper
onClick={handleCancel}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleCancel();
}
}}
aria-label={t("name_manager_dialog.back_to_list")}
tabIndex={0}
>
<ArrowLeft />
</IconButtonWrapper>
</Tooltip>
<EditHeaderTitle>{headerTitle}</EditHeaderTitle>
<Tooltip
title={t("right_drawer.close")}
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -8],
},
},
],
},
}}
>
<IconButtonWrapper
onClick={onClose}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onClose();
}
}}
aria-label={t("right_drawer.close")}
tabIndex={0}
>
<X />
</IconButtonWrapper>
</Tooltip>
</EditHeader>
<Content>
<EditNamedRange
name={name}
scope={scopeName}
formula={formula}
onSave={handleSave}
onCancel={handleCancel}
editingDefinedName={editingDefinedName}
model={model}
/>
</Content>
</Container>
);
}
const currentSelectedArea = getSelectedArea();
const definedNameList = model.getDefinedNameList();
const onNameSelected = (formula: string) => {
const range = parseRangeInSheet(model, formula);
if (range) {
const [sheetIndex, rowStart, columnStart, rowEnd, columnEnd] = range;
model.setSelectedSheet(sheetIndex);
model.setSelectedCell(rowStart, columnStart);
model.setSelectedRange(rowStart, columnStart, rowEnd, columnEnd);
}
onUpdate();
};
return (
<Container>
<Header>
<HeaderTitle>{t("name_manager_dialog.title")}</HeaderTitle>
<Tooltip
title={t("right_drawer.close")}
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -8],
},
},
],
},
}}
>
<IconButtonWrapper
onClick={onClose}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onClose();
}
}}
aria-label={t("right_drawer.close")}
tabIndex={0}
>
<X />
</IconButtonWrapper>
</Tooltip>
</Header>
<Content>
{definedNameList.length === 0 ? (
<EmptyStateMessage>
<IconWrapper>
<PackageOpen />
</IconWrapper>
{t("name_manager_dialog.empty_message1")}
<br />
{t("name_manager_dialog.empty_message2")}
</EmptyStateMessage>
) : (
<ListContainer>
{definedNameList.map((definedName) => {
const worksheets = model.getWorksheetsProperties();
const scopeName =
definedName.scope != null
? worksheets[definedName.scope]?.name || "[Unknown]"
: "[Global]";
const isSelected =
currentSelectedArea !== null &&
normalizeRangeString(definedName.formula) ===
normalizeRangeString(currentSelectedArea);
return (
<ListItem
key={`${definedName.name}-${definedName.scope}`}
tabIndex={0}
$isSelected={isSelected}
onClick={() => {
// select the area corresponding to the defined name
const formula = definedName.formula;
const range = parseRangeInSheet(model, formula);
if (range) {
const [
sheetIndex,
rowStart,
columnStart,
rowEnd,
columnEnd,
] = range;
model.setSelectedSheet(sheetIndex);
model.setSelectedCell(rowStart, columnStart);
model.setSelectedRange(
rowStart,
columnStart,
rowEnd,
columnEnd,
);
}
onUpdate();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onNameSelected(definedName.formula);
}
}}
>
<ListItemText>
<NameText>{definedName.name}</NameText>
<ScopeText>{scopeName}</ScopeText>
<FormulaText>{definedName.formula}</FormulaText>
</ListItemText>
<IconsWrapper>
<Tooltip title={t("name_manager_dialog.edit")}>
<IconButton
onClick={(e) => {
e.stopPropagation();
handleListItemClick(definedName);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
handleListItemClick(definedName);
}
}}
aria-label={t("name_manager_dialog.edit")}
tabIndex={0}
>
<PencilLine size={16} />
</IconButton>
</Tooltip>
<Tooltip title={t("name_manager_dialog.delete")}>
<IconButton
onClick={(e) => {
e.stopPropagation();
model.deleteDefinedName(
definedName.name,
definedName.scope ?? null,
);
onUpdate();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
model.deleteDefinedName(
definedName.name,
definedName.scope ?? null,
);
onUpdate();
}
}}
aria-label={t("name_manager_dialog.delete")}
tabIndex={0}
>
<Trash2 size={16} />
</IconButton>
</Tooltip>
</IconsWrapper>
</ListItem>
);
})}
</ListContainer>
)}
</Content>
<Footer>
<HelpLink
href="https://docs.ironcalc.com/web-application/name-manager.html"
target="_blank"
rel="noopener noreferrer"
>
<BookOpen />
{t("name_manager_dialog.help")}
</HelpLink>
<NewButton
variant="contained"
disableElevation
startIcon={<Plus size={16} />}
onClick={handleNewClick}
>
{t("name_manager_dialog.new")}
</NewButton>
</Footer>
</Container>
);
};
const Container = styled("div")({
height: "100%",
display: "flex",
flexDirection: "column",
});
const Content = styled("div")({
flex: 1,
color: theme.palette.grey[700],
lineHeight: "1.5",
overflow: "auto",
});
const ListContainer = styled("div")({
display: "flex",
flexDirection: "column",
});
const ListItem = styled("div")<{ $isSelected: boolean }>(({ $isSelected }) => ({
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
padding: "8px 12px",
cursor: "pointer",
minHeight: "40px",
boxSizing: "border-box",
borderBottom: `1px solid ${theme.palette.grey[200]}`,
paddingLeft: $isSelected ? "20px" : "12px",
transition: "all 0.2s ease-in-out",
borderLeft: $isSelected
? `3px solid ${theme.palette.primary.main}`
: "3px solid transparent",
"&:hover": {
backgroundColor: theme.palette.grey[50],
paddingLeft: "20px",
},
}));
const ListItemText = styled("div")({
fontSize: "12px",
color: theme.palette.common.black,
fontFamily: theme.typography.fontFamily,
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: "2px",
});
const ScopeText = styled("span")({
fontSize: "12px",
color: theme.palette.common.black,
});
const FormulaText = styled("span")({
fontSize: "12px",
color: theme.palette.grey[600],
});
const NameText = styled("span")({
fontSize: "12px",
color: theme.palette.common.black,
fontWeight: 600,
});
const IconsWrapper = styled("div")({
display: "flex",
alignItems: "center",
gap: "2px",
});
const IconButton = styled("div")({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "24px",
height: "24px",
borderRadius: "4px",
backgroundColor: "transparent",
cursor: "pointer",
"&:hover": {
backgroundColor: theme.palette.grey[200],
},
});
export const Footer = styled("div")`
padding: 8px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: ${theme.palette.grey["600"]};
border-top: 1px solid ${theme.palette.grey["300"]};
gap: 8px;
`;
const HelpLink = styled("a")`
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 400;
font-family: "Inter";
color: ${theme.palette.grey["600"]};
text-decoration: none;
&:hover {
text-decoration: underline;
}
svg {
width: 16px;
height: 16px;
}
`;
export const NewButton = styled(Button)`
text-transform: none;
min-width: fit-content;
font-size: 12px;
&.MuiButton-colorSecondary {
background-color: ${theme.palette.grey[200]};
color: ${theme.palette.grey[700]};
&:hover {
background-color: ${theme.palette.grey[300]};
}
}
`;
const Header = styled("div")({
height: "40px",
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
padding: "0 8px",
borderBottom: `1px solid ${theme.palette.grey[300]}`,
});
const HeaderTitle = styled("div")({
width: "100%",
fontSize: "12px",
});
const EditHeader = styled("div")({
height: "40px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 8px",
gap: "8px",
borderBottom: `1px solid ${theme.palette.grey[300]}`,
});
const EditHeaderTitle = styled("div")({
flex: 1,
fontSize: "12px",
fontWeight: 500,
});
const IconButtonWrapper = styled("div")`
&:hover {
background-color: ${theme.palette.grey["50"]};
}
display: flex;
border-radius: 4px;
height: 24px;
width: 24px;
cursor: pointer;
align-items: center;
justify-content: center;
svg {
width: 16px;
height: 16px;
stroke-width: 1.5;
}
`;
const EmptyStateMessage = styled("div")`
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
justify-content: center;
text-align: center;
width: 100%;
height: 100%;
font-size: 12px;
color: ${theme.palette.grey["600"]};
font-family: "Inter";
z-index: 0;
margin: auto 0px;
position: relative;
`;
const IconWrapper = styled("div")`
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 4px;
background-color: ${theme.palette.grey["100"]};
color: ${theme.palette.grey["600"]};
svg {
width: 16px;
height: 16px;
stroke-width: 2;
}
`;
export default NamedRanges;