@evergis/react 4.0.156 → 4.0.158

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5294,8 +5294,7 @@ const DashboardWrapper = styled(uilibGl.Flex).withConfig({ displayName: "Dashboa
5294
5294
  width: calc(100% - 3rem);
5295
5295
  height: calc(100% - ${LEFT_PANEL_HEADER_HEIGHT});
5296
5296
 
5297
- ${({ hasImage, isPresentationMode }) => hasImage &&
5298
- !isPresentationMode &&
5297
+ ${({ hasImage }) => hasImage &&
5299
5298
  styled.css `
5300
5299
  margin-top: -0.35rem;
5301
5300
  `}
@@ -5905,7 +5904,7 @@ const useAttachmentDownload = (items) => {
5905
5904
  return;
5906
5905
  inFlightRef.current.add(item.link);
5907
5906
  api.catalog
5908
- .getFile(item.link)
5907
+ .getFile({ resourceId: item.link })
5909
5908
  .then(blob => saveBlobAsFile(blob, item.name))
5910
5909
  .catch(error => {
5911
5910
  notification?.add({
@@ -6188,7 +6187,7 @@ const useAttachmentPreviewImages = ({ items, active, }) => {
6188
6187
  return;
6189
6188
  inFlightRef.current.add(item.link);
6190
6189
  api.catalog
6191
- .getFile(item.link)
6190
+ .getFile({ resourceId: item.link })
6192
6191
  .then(blob => {
6193
6192
  const objectUrl = URL.createObjectURL(blob);
6194
6193
  setBlobUrls(prev => ({ ...prev, [item.link]: objectUrl }));
@@ -6575,7 +6574,7 @@ const useMapImages = ({ images }) => {
6575
6574
  // Картинка из каталога
6576
6575
  if (!config.url.startsWith("data:image") && !config.url.startsWith("http")) {
6577
6576
  try {
6578
- const { data } = await loadSvgAsImage(URL.createObjectURL(await api.catalog.getFile(config.url)), size, pixelRatio);
6577
+ const { data } = await loadSvgAsImage(URL.createObjectURL(await api.catalog.getFile({ resourceId: config.url })), size, pixelRatio);
6579
6578
  map.current.addImage(config.name, data, {
6580
6579
  sdf,
6581
6580
  pixelRatio,
@@ -9128,7 +9127,7 @@ const FileImagePreview = ({ link, isExternal, size, borderRadius, }) => {
9128
9127
  let cancelled = false;
9129
9128
  let objectUrl;
9130
9129
  api.catalog
9131
- .getFile(link)
9130
+ .getFile({ resourceId: link })
9132
9131
  .then(blob => {
9133
9132
  if (cancelled)
9134
9133
  return;
@@ -11144,7 +11143,7 @@ const LayersContainerWrapper = styled(Container).withConfig({ displayName: "Laye
11144
11143
  }
11145
11144
 
11146
11145
  ${LayerGroupList} {
11147
- margin: ${({ isPresentationMode }) => (isPresentationMode ? 0 : "0 -1.25rem 0 -1rem")};
11146
+ margin: 0 -1.25rem 0 -1rem;
11148
11147
  padding: 0;
11149
11148
  }
11150
11149
  `;
@@ -11735,6 +11734,8 @@ const NUMBER_EDITOR_TYPES = [
11735
11734
  const INTEGER_ATTRIBUTE_TYPES = [api.AttributeType.Int32, api.AttributeType.Int64];
11736
11735
  /** Тип атрибута, когда его не задали ни конфиг, ни источник. */
11737
11736
  const DEFAULT_ATTRIBUTE_TYPE = api.AttributeType.String;
11737
+ /** Контрол колонки со списком значений, когда `control` в описании атрибута не задан. */
11738
+ const DEFAULT_ATTRIBUTE_CONTROL = "dropdown";
11738
11739
  /** Сколько миллисекунд висит нотификация об ошибке записи. */
11739
11740
  const SAVE_ERROR_DURATION = 5000;
11740
11741
 
@@ -12014,6 +12015,53 @@ const buildStructuredDataSchema = ({ attributesDescription, attributesConfigurat
12014
12015
  return sourceAttributes.map(source => toSchemaAttribute({ source, idAttribute, canWriteToSource }));
12015
12016
  };
12016
12017
 
12018
+ /**
12019
+ * Записи связанного источника → пункты списка.
12020
+ *
12021
+ * Пустые значения отбрасываются: выбрать «ничего» можно и без них, а пункт с пустым значением
12022
+ * стёр бы ячейку под видом осмысленного выбора. Повторы схлопываются — у справочника по
12023
+ * значению одна запись, а у сырого слоя их может быть сколько угодно.
12024
+ */
12025
+ const featuresToListOptions = (features, valueAttribute, textAttribute) => lodash.uniqBy(features
12026
+ .filter(({ properties }) => !lodash.isNil(properties?.[valueAttribute]))
12027
+ .map(({ properties }) => ({
12028
+ value: properties[valueAttribute],
12029
+ text: String(properties[textAttribute] ?? properties[valueAttribute]),
12030
+ })), ({ value }) => String(value));
12031
+ /**
12032
+ * Список допустимых значений колонки: варианты из конфига, а без них — записи связанного
12033
+ * источника. `undefined` — списка нет, и значение вводится вручную.
12034
+ *
12035
+ * Источник, ещё не загруженный или не найденный, даёт пустой список, а не его отсутствие:
12036
+ * колонка со списком не должна на время загрузки превращаться в свободный ввод.
12037
+ */
12038
+ const getAttributeListOptions = (description, dataSources) => {
12039
+ const { attributeName, variants, relatedDataSource, attributeValue, attributeAlias } = description || {};
12040
+ if (variants?.length) {
12041
+ return variants;
12042
+ }
12043
+ if (!relatedDataSource) {
12044
+ return undefined;
12045
+ }
12046
+ const valueAttribute = attributeValue || attributeName;
12047
+ const features = getDataSource(relatedDataSource, dataSources)?.features ?? [];
12048
+ return featuresToListOptions(features, valueAttribute, attributeAlias || valueAttribute);
12049
+ };
12050
+ /**
12051
+ * Схема представления: схема данных плюс списки значений и их контролы.
12052
+ *
12053
+ * Отдельно от схемы данных потому, что зависит от загруженных источников страницы: черновик
12054
+ * пересобирает строки при каждой смене схемы, и загрузка справочника стирала бы в нём правки.
12055
+ */
12056
+ const withListControls = (schema, attributesDescription, dataSources) => schema.map(attribute => {
12057
+ const description = attributesDescription?.find(({ attributeName }) => attributeName === attribute.attributeName);
12058
+ const listOptions = getAttributeListOptions(description, dataSources);
12059
+ if (!listOptions) {
12060
+ return attribute;
12061
+ }
12062
+ return { ...attribute, listOptions, control: description?.control ?? DEFAULT_ATTRIBUTE_CONTROL };
12063
+ });
12064
+
12017
12065
  /**
12018
12066
  * Схема структурированных данных и источник, к которому она привязана.
12019
12067
  *
@@ -12026,6 +12074,9 @@ const buildStructuredDataSchema = ({ attributesDescription, attributesConfigurat
12026
12074
  *
12027
12075
  * Берётся из конфига страницы, а не из загруженного источника: от него зависит, какие ячейки
12028
12076
  * редактируемы, и до окончания загрузки схема не должна меняться.
12077
+ *
12078
+ * `viewSchema` — та же схема со списками значений для представления. Списки зависят от
12079
+ * загруженных источников страницы, поэтому в схему данных (черновик, запись) они не входят.
12029
12080
  */
12030
12081
  const useStructuredDataSchema = (type, elementConfig) => {
12031
12082
  const { dataSources } = useWidgetContext(type);
@@ -12038,7 +12089,8 @@ const useStructuredDataSchema = (type, elementConfig) => {
12038
12089
  const layerName = React.useMemo(() => (saveToLayer ? configDataSource?.layerName : undefined), [configDataSource?.layerName, saveToLayer]);
12039
12090
  const attributesConfiguration = React.useMemo(() => (relatedDataSource ? getAttributesConfiguration(layerInfo) : undefined), [layerInfo, relatedDataSource]);
12040
12091
  const schema = React.useMemo(() => buildStructuredDataSchema({ attributesDescription, attributesConfiguration, canWriteToSource: !!layerName }), [attributesDescription, attributesConfiguration, layerName]);
12041
- return { schema, dataSource, layerName };
12092
+ const viewSchema = React.useMemo(() => withListControls(schema, attributesDescription, dataSources), [attributesDescription, dataSources, schema]);
12093
+ return { schema, viewSchema, dataSource, layerName };
12042
12094
  };
12043
12095
 
12044
12096
  /**
@@ -12060,7 +12112,7 @@ const useStructuredData = (type, elementConfig) => {
12060
12112
  const { filters } = useWidgetContext(type);
12061
12113
  const { currentPage } = useWidgetPage(type);
12062
12114
  const { relatedDataSource, filterName, editMode } = elementConfig?.options || {};
12063
- const { schema, dataSource, layerName } = useStructuredDataSchema(type, elementConfig);
12115
+ const { schema, viewSchema, dataSource, layerName } = useStructuredDataSchema(type, elementConfig);
12064
12116
  const hasDataSource = !!relatedDataSource;
12065
12117
  const configFilter = React.useMemo(() => getConfigFilter(filterName, currentPage?.filters), [currentPage?.filters, filterName]);
12066
12118
  const filterValue = React.useMemo(() => (filterName ? filters?.[filterName]?.value ?? configFilter?.defaultValue : undefined), [configFilter?.defaultValue, filterName, filters]);
@@ -12102,14 +12154,14 @@ const useStructuredData = (type, elementConfig) => {
12102
12154
  setDeletingKey(null);
12103
12155
  }, [deleteRow, deletingKey]);
12104
12156
  const contextValue = React.useMemo(() => ({
12105
- schema,
12157
+ schema: viewSchema,
12106
12158
  rows: visibleRows,
12107
12159
  loading,
12108
12160
  canEdit,
12109
12161
  canDelete: canChangeRows,
12110
12162
  onCellChange: changeCell,
12111
12163
  onRowDelete,
12112
- }), [canChangeRows, canEdit, changeCell, loading, onRowDelete, schema, visibleRows]);
12164
+ }), [canChangeRows, canEdit, changeCell, loading, onRowDelete, viewSchema, visibleRows]);
12113
12165
  return {
12114
12166
  contextValue,
12115
12167
  dirty,
@@ -16170,6 +16222,12 @@ const ATTACHMENTS_INLINE_LIMIT = 1;
16170
16222
  * Величина с макета и опцией не выносится: поп-ап у колонки один на все таблицы.
16171
16223
  */
16172
16224
  const ATTACHMENTS_SHOWN_ITEMS = 3;
16225
+ /**
16226
+ * Пустой пункт выпадающего списка в ячейке: выбор снимает значение. У `Dropdown` пункт с пустым
16227
+ * значением показывается как плейсхолдер, поэтому и подпись у него — тот же прочерк, что у
16228
+ * пустой ячейки.
16229
+ */
16230
+ const EMPTY_LIST_OPTION = { text: "—", value: "" };
16173
16231
  /** Вид списка, с которого поп-ап вложений открывается. */
16174
16232
  const ATTACHMENTS_VIEW_MODE = "list";
16175
16233
 
@@ -16463,7 +16521,27 @@ const ColorSwatch = styled.div.withConfig({ displayName: "ColorSwatch", componen
16463
16521
  background-color: ${({ $color }) => $color};
16464
16522
  border-radius: ${({ theme: { borderRadius } }) => borderRadius.tiny};
16465
16523
  `;
16466
- const TableEmpty = styled.div.withConfig({ displayName: "TableEmpty", componentId: "sc-10hkdmv" }) `
16524
+ /**
16525
+ * Ряд кнопок колонки со списком. Не влезает в ширину колонки — переносится, и строка таблицы
16526
+ * растёт вниз: резать выбор многоточием, как текст, нельзя.
16527
+ *
16528
+ * Зазор между кнопками по горизонтали даёт их собственный правый отступ, поэтому здесь задан
16529
+ * только межстрочный. Отступы ряда — как у текстовой ячейки, чтобы значения соседних колонок
16530
+ * стояли на одной линии.
16531
+ */
16532
+ const ChipsCellBox = styled.div.withConfig({ displayName: "ChipsCellBox", componentId: "sc-1qxeyw" }) `
16533
+ display: flex;
16534
+ flex-wrap: wrap;
16535
+ align-items: center;
16536
+ row-gap: 0.25rem;
16537
+ padding: 0.25rem;
16538
+
16539
+ /* Удвоенный класс перебивает собственный указатель кнопки-чипа. */
16540
+ && > * {
16541
+ cursor: ${({ $interactive }) => ($interactive ? "pointer" : "default")};
16542
+ }
16543
+ `;
16544
+ const TableEmpty = styled.div.withConfig({ displayName: "TableEmpty", componentId: "sc-jwp9aw" }) `
16467
16545
  padding: 0.75rem 0.25rem;
16468
16546
  color: ${({ theme }) => theme.palette.textSecondary};
16469
16547
  `;
@@ -16651,6 +16729,175 @@ const AttachmentsCell = React.memo(({ attribute, row, canEdit, onChange }) => {
16651
16729
  }) }, state.previewIndex))] }));
16652
16730
  });
16653
16731
 
16732
+ const FilterChip = styled.div.withConfig({ displayName: "FilterChip", componentId: "sc-17fod1d" }) `
16733
+ display: inline-flex;
16734
+ align-items: center;
16735
+ gap: 0.25rem;
16736
+ padding: 0.3125rem 0.5rem;
16737
+ height: 1.5rem;
16738
+ border-radius: 0.25rem;
16739
+ background-color: ${({ $isActive, $bgColor, theme }) => $bgColor
16740
+ ? $bgColor
16741
+ : $isActive
16742
+ ? theme.palette?.primary
16743
+ : theme.palette?.elementLight};
16744
+ color: ${({ $isActive, $textColor, theme }) => $textColor
16745
+ ? $textColor
16746
+ : $isActive
16747
+ ? theme.palette?.textContrast
16748
+ : theme.palette?.textSecondary};
16749
+ cursor: pointer;
16750
+ font-size: 0.75rem;
16751
+ line-height: 0.875rem;
16752
+ white-space: nowrap;
16753
+ flex-shrink: 0;
16754
+ transition: all 0.2s ease-in-out;
16755
+ margin: 0 0.25rem 0 0;
16756
+ box-sizing: border-box;
16757
+
16758
+ &:hover {
16759
+ background-color: ${({ $isActive, $bgColor, theme }) => $isActive
16760
+ ? $bgColor || 'inherit'
16761
+ : $bgColor || theme.palette?.elementDark};
16762
+ }
16763
+ `;
16764
+ const ChipIconWrapper = styled.span.withConfig({ displayName: "ChipIconWrapper", componentId: "sc-8j3rkq" }) `
16765
+ display: inline-flex;
16766
+ align-items: center;
16767
+ justify-content: center;
16768
+ width: 0.875rem;
16769
+ height: 0.875rem;
16770
+ flex-shrink: 0;
16771
+
16772
+ svg, img {
16773
+ width: 100%;
16774
+ height: 100%;
16775
+ display: block;
16776
+ }
16777
+ `;
16778
+ const ChipText = styled.span.withConfig({ displayName: "ChipText", componentId: "sc-1saygd0" }) `
16779
+ overflow: hidden;
16780
+ text-overflow: ellipsis;
16781
+ white-space: nowrap;
16782
+ ${({ $maxTextWidth }) => $maxTextWidth && `max-width: ${$maxTextWidth / 16}rem;`}
16783
+ `;
16784
+
16785
+ const CustomChip = ({ text, icon, color, primary, secondary, error, disabled, isActive, maxTextWidth, fontColor: customFontColor, backgroundColor: customBackgroundColor, ...props }) => {
16786
+ const theme = styled.useTheme();
16787
+ const { palette } = theme;
16788
+ // Helper to add transparency to hex color
16789
+ const addTransparency = (hex, transparency = '1f') => {
16790
+ // Ensure hex starts with #
16791
+ const cleanHex = hex.startsWith('#') ? hex : `#${hex}`;
16792
+ return `${cleanHex}${transparency}`;
16793
+ };
16794
+ // Calculate colors based on priority:
16795
+ // 1. customFontColor / customBackgroundColor (highest, from options)
16796
+ // 2. color (from attribute)
16797
+ // 3. primary/secondary/error
16798
+ // 4. default theme colors
16799
+ let bgColor;
16800
+ let textColor;
16801
+ if (isActive) {
16802
+ // Priority 1: Custom colors from options
16803
+ if (customFontColor) {
16804
+ textColor = customFontColor;
16805
+ bgColor = customBackgroundColor || addTransparency(customFontColor); // 12% opacity
16806
+ }
16807
+ // Priority 2: Color from attribute
16808
+ else if (color) {
16809
+ textColor = palette.textPrimary; // White text for active chip
16810
+ bgColor = color; // Full color without transparency
16811
+ }
16812
+ // Priority 3: Variant colors
16813
+ else if (primary) {
16814
+ bgColor = theme.message.color.success; // Green
16815
+ textColor = palette.textContrast;
16816
+ }
16817
+ else if (secondary) {
16818
+ bgColor = palette.primary;
16819
+ textColor = palette.textContrast;
16820
+ }
16821
+ else if (error) {
16822
+ bgColor = theme.message.color.warning; // Yellow
16823
+ textColor = palette.textPrimary;
16824
+ }
16825
+ // Priority 4: Default - no colors specified
16826
+ else {
16827
+ textColor = palette.primary;
16828
+ bgColor = addTransparency(palette.primary); // palette.primary + 12%
16829
+ }
16830
+ }
16831
+ else {
16832
+ // Inactive state
16833
+ if (color) {
16834
+ textColor = color; // Use color for text
16835
+ bgColor = addTransparency(color); // Uses default 1f (12%) transparency
16836
+ }
16837
+ // else: No color attribute - leave bgColor and textColor undefined
16838
+ // Styled component will use fallback for inactive: bg = rgba(0,0,0,0.06), text = palette.textSecondary
16839
+ }
16840
+ const renderIcon = React.useCallback((iconValue) => {
16841
+ if (!iconValue) {
16842
+ return null;
16843
+ }
16844
+ // If icon doesn't contain a dot and doesn't start with /sp/, it's a built-in icon from uilib-gl
16845
+ if (!iconValue.includes(".") && !iconValue.startsWith("/sp/")) {
16846
+ return jsxRuntime.jsx(uilibGl.Icon, { kind: iconValue });
16847
+ }
16848
+ // If it's an SVG file or resourceId path, use SvgImage component
16849
+ const isSvg = iconValue.endsWith(".svg") || iconValue.startsWith("/sp/resources/file/");
16850
+ if (isSvg) {
16851
+ const iconColor = textColor
16852
+ ? textColor
16853
+ : isActive
16854
+ ? palette.textContrast
16855
+ : palette.textSecondary;
16856
+ return (jsxRuntime.jsx(SvgImage, { url: iconValue, width: 14, height: 14, fontColor: iconColor }));
16857
+ }
16858
+ // Otherwise, it's a regular image
16859
+ return jsxRuntime.jsx("img", { src: iconValue, alt: "" });
16860
+ }, [isActive, textColor, palette.textContrast, palette.textSecondary]);
16861
+ return (jsxRuntime.jsxs(FilterChip, { "$isActive": isActive, "$bgColor": bgColor, "$textColor": textColor, ...props, children: [icon && jsxRuntime.jsx(ChipIconWrapper, { children: renderIcon(icon) }), jsxRuntime.jsx(ChipText, { "$maxTextWidth": maxTextWidth, children: text })] }));
16862
+ };
16863
+
16864
+ /**
16865
+ * Значение ячейки совпадает со значением пункта списка.
16866
+ *
16867
+ * Сравниваем строками: у варианта из конфига значение бывает строкой, а в ячейке числового
16868
+ * атрибута лежит число, и строгое сравнение не узнало бы собственный выбор.
16869
+ */
16870
+ const isSameListValue = (left, right) => {
16871
+ if (lodash.isNil(left) || lodash.isNil(right)) {
16872
+ return lodash.isNil(left) && lodash.isNil(right);
16873
+ }
16874
+ return String(left) === String(right);
16875
+ };
16876
+ /** Пункт списка, выбранный в ячейке. Нет — значение в ячейке не из списка либо пусто. */
16877
+ const findListOption = (listOptions, value) => lodash.isNil(value) ? undefined : listOptions.find(({ value: optionValue }) => isSameListValue(optionValue, value));
16878
+
16879
+ /**
16880
+ * Колонка со списком значений в виде ряда кнопок (`control: "chips"`).
16881
+ *
16882
+ * Ряд виден всегда, как галка у логической колонки: выбор — один клик, без открытия редактора.
16883
+ * Выбранный пункт подсвечен, клик по нему снимает выбор — иначе очистить ячейку было бы нечем.
16884
+ * Без права правки ряд остаётся на месте и показывает выбор, но на клики не отвечает.
16885
+ *
16886
+ * Кнопки — те же, что у фильтра-чипсов: вариант со своим `color`/`icon` выглядит одинаково и там,
16887
+ * и здесь.
16888
+ */
16889
+ const ChipsCell = React.memo(({ attribute, row, canEdit, onChange }) => {
16890
+ const { attributeName, isEditable, listOptions = [] } = attribute;
16891
+ const value = row.properties[attributeName];
16892
+ // Те же два условия, что и у обычной ячейки: режим таблицы и разрешение самого атрибута.
16893
+ const editable = canEdit && isEditable;
16894
+ const handleClick = React.useCallback((optionValue) => onChange(row.key, attributeName, isSameListValue(optionValue, value) ? null : optionValue), [attributeName, onChange, row.key, value]);
16895
+ if (!listOptions.length) {
16896
+ return (jsxRuntime.jsx(CellText, { children: jsxRuntime.jsx(CellPlaceholder, { children: "\u2014" }) }));
16897
+ }
16898
+ return (jsxRuntime.jsx(ChipsCellBox, { "$interactive": editable, children: listOptions.map(({ text, value: optionValue, color, icon }) => (jsxRuntime.jsx(CustomChip, { text: text, title: text, color: color, icon: icon ? getResourceUrl(icon) : undefined, isActive: isSameListValue(optionValue, value), onClick: editable ? () => handleClick(optionValue) : undefined }, String(optionValue)))) }));
16899
+ });
16900
+
16654
16901
  /**
16655
16902
  * Колонка цвета (`colorPicker` у атрибута).
16656
16903
  *
@@ -16672,20 +16919,6 @@ const ColorCell = React.memo(({ attribute, row, canEdit, onChange }) => {
16672
16919
  return (jsxRuntime.jsxs(ColorCellBox, { children: [editable ? (jsxRuntime.jsx(uilibGl.ColorPicker, { zIndex: DASHBOARD_OVERLAY_Z_INDEX, withOpacity: true, value: isValidColor ? color : new color$1.Color(DEFAULT_CELL_COLOR), onChange: handleChange })) : (isValidColor && jsxRuntime.jsx(ColorSwatch, { "$color": text })), jsxRuntime.jsx(CellText, { title: text, children: text || jsxRuntime.jsx(CellPlaceholder, { children: "\u2014" }) })] }));
16673
16920
  });
16674
16921
 
16675
- /**
16676
- * Значение ячейки не изменилось.
16677
- *
16678
- * Даты сравниваем моментом времени, а не строкой: `DatePicker` отдаёт ISO с миллисекундами и
16679
- * повторяет текущее значение на каждый уход фокуса из поля — без этой проверки строка
16680
- * помечалась бы изменённой от одного лишь клика по иконке календаря.
16681
- */
16682
- const isSameCellValue = (next, current, type) => {
16683
- if (type === api.AttributeType.DateTime && next && current) {
16684
- return new Date(next).getTime() === new Date(current).getTime();
16685
- }
16686
- return next === current;
16687
- };
16688
-
16689
16922
  /**
16690
16923
  * Правка одной ячейки.
16691
16924
  *
@@ -16755,9 +16988,85 @@ const useCellEditing = () => {
16755
16988
  document.addEventListener("mousedown", onDocumentMouseDown);
16756
16989
  return () => document.removeEventListener("mousedown", onDocumentMouseDown);
16757
16990
  }, [editing]);
16991
+ // Закрыть правку из самого редактора — так же, как Enter: выбор из списка завершает правку сразу,
16992
+ // и держать открытым редактор с уже записанным значением незачем.
16993
+ const close = React.useCallback(() => setMode("focused"), []);
16758
16994
  const buttonProps = React.useMemo(() => ({ ref: buttonRef, onFocus, onBlur, onClick }), [onBlur, onClick, onFocus]);
16759
16995
  const editorProps = React.useMemo(() => ({ ref: editorRef, onBlur: onEditorBlur, onKeyDown: onEditorKeyDown }), [onEditorBlur, onEditorKeyDown]);
16760
- return { editing, buttonProps, editorProps };
16996
+ return { editing, buttonProps, editorProps, close };
16997
+ };
16998
+
16999
+ /**
17000
+ * Раскрывает `Dropdown` сразу, как ячейка перешла в правку.
17001
+ *
17002
+ * Правку открывает фокус ячейки, а своего «открыться сразу» у `Dropdown` нет: без этого список
17003
+ * пришлось бы раскрывать вторым кликом, а с Tab-а — ещё и Enter-ом. Меню переключает клик по полю
17004
+ * списка; поле — родитель скрытого `input`, которым `Dropdown` ловит клавиатуру (`FocusTrap`), и
17005
+ * другого инпута внутри нет: поиск по пунктам живёт в портале.
17006
+ */
17007
+ const useDropdownAutoOpen = (editing) => {
17008
+ const fieldRef = React.useRef(null);
17009
+ React.useEffect(() => {
17010
+ if (editing) {
17011
+ fieldRef.current?.querySelector("input")?.parentElement?.click();
17012
+ }
17013
+ }, [editing]);
17014
+ return fieldRef;
17015
+ };
17016
+
17017
+ /**
17018
+ * Колонка со списком значений в виде выпадающего списка (`control: "dropdown"`).
17019
+ *
17020
+ * Вне фокуса показывает подпись выбранного пункта, а сам список встаёт на фокус — как редакторы
17021
+ * остальных ячеек. Значение не из списка (справочник поменялся, строка пришла из источника)
17022
+ * показывается как есть, по `stringFormat` колонки: так видно, что в данных, а не пустота.
17023
+ */
17024
+ const DropdownCell = React.memo(({ attribute, row, canEdit, onChange }) => {
17025
+ const { t } = useGlobalContext();
17026
+ const { editing, buttonProps, editorProps, close } = useCellEditing();
17027
+ const fieldRef = useDropdownAutoOpen(editing);
17028
+ const { attributeName, type, isEditable, stringFormat, multiline, listOptions = [] } = attribute;
17029
+ const value = row.properties[attributeName];
17030
+ const text = React.useMemo(() => {
17031
+ if (lodash.isNil(value)) {
17032
+ return "";
17033
+ }
17034
+ return findListOption(listOptions, value)?.text ?? formatAttributeValue({ t, type, value, stringFormat });
17035
+ }, [listOptions, stringFormat, t, type, value]);
17036
+ // Значения пунктов `Dropdown` сравнивает строго и только строками — числовые варианты иначе
17037
+ // не нашли бы себя в выбранном. Первым идёт пустой пункт: без него очистить ячейку нечем.
17038
+ const dropdownOptions = React.useMemo(() => [EMPTY_LIST_OPTION, ...listOptions.map(({ text: optionText, value: optionValue }) => ({
17039
+ text: optionText,
17040
+ value: String(optionValue),
17041
+ }))], [listOptions]);
17042
+ const handleChange = React.useCallback(([selected]) => {
17043
+ const next = findListOption(listOptions, selected?.value)?.value ?? null;
17044
+ if (!isSameListValue(next, value)) {
17045
+ onChange(row.key, attributeName, next);
17046
+ }
17047
+ close();
17048
+ }, [attributeName, close, listOptions, onChange, row.key, value]);
17049
+ if (!canEdit || !isEditable) {
17050
+ return (jsxRuntime.jsx(CellText, { "$multiline": multiline, title: text, children: text || jsxRuntime.jsx(CellPlaceholder, { children: "\u2014" }) }));
17051
+ }
17052
+ if (!editing) {
17053
+ return (jsxRuntime.jsx(CellButton, { type: "button", "$multiline": multiline, ...buttonProps, children: text || jsxRuntime.jsx(CellPlaceholder, { children: "\u2014" }) }));
17054
+ }
17055
+ return (jsxRuntime.jsxs(CellEditor, { ...editorProps, children: [jsxRuntime.jsx(CellGhost, { "$multiline": multiline, children: text || "—" }), jsxRuntime.jsx(CellField, { ref: fieldRef, children: jsxRuntime.jsx(uilibGl.Dropdown, { zIndex: DASHBOARD_OVERLAY_Z_INDEX, searchable: true, searchPlaceholder: t("structuredData.searchPlaceholder", { ns: "dashboard", defaultValue: "Найти" }), width: "100%", menuMinWidth: "12rem", options: dropdownOptions, value: lodash.isNil(value) ? "" : String(value), onChange: handleChange }) })] }));
17056
+ });
17057
+
17058
+ /**
17059
+ * Значение ячейки не изменилось.
17060
+ *
17061
+ * Даты сравниваем моментом времени, а не строкой: `DatePicker` отдаёт ISO с миллисекундами и
17062
+ * повторяет текущее значение на каждый уход фокуса из поля — без этой проверки строка
17063
+ * помечалась бы изменённой от одного лишь клика по иконке календаря.
17064
+ */
17065
+ const isSameCellValue = (next, current, type) => {
17066
+ if (type === api.AttributeType.DateTime && next && current) {
17067
+ return new Date(next).getTime() === new Date(current).getTime();
17068
+ }
17069
+ return next === current;
16761
17070
  };
16762
17071
 
16763
17072
  /**
@@ -16770,7 +17079,7 @@ const useCellEditing = () => {
16770
17079
  const TableCell = React.memo(({ attribute, row, canEdit, onChange }) => {
16771
17080
  const { t, language } = useGlobalContext();
16772
17081
  const { editing, buttonProps, editorProps } = useCellEditing();
16773
- const { attributeName, type, subType, isEditable, stringFormat, multiline, colorPicker } = attribute;
17082
+ const { attributeName, type, subType, isEditable, stringFormat, multiline, colorPicker, control, listOptions } = attribute;
16774
17083
  const value = row.properties[attributeName];
16775
17084
  const handleChange = React.useCallback((next) => {
16776
17085
  // Пустые правки отбрасываем: иначе строка становилась бы «изменённой» от простого
@@ -16799,6 +17108,11 @@ const TableCell = React.memo(({ attribute, row, canEdit, onChange }) => {
16799
17108
  if (colorPicker) {
16800
17109
  return jsxRuntime.jsx(ColorCell, { attribute: attribute, row: row, canEdit: canEdit, onChange: onChange });
16801
17110
  }
17111
+ // Колонка со списком значений: вводить можно только то, что в списке, поэтому и редактор у неё
17112
+ // свой — ряд кнопок либо выпадающий список, каким бы ни был тип атрибута.
17113
+ if (listOptions) {
17114
+ return control === "chips" ? (jsxRuntime.jsx(ChipsCell, { attribute: attribute, row: row, canEdit: canEdit, onChange: onChange })) : (jsxRuntime.jsx(DropdownCell, { attribute: attribute, row: row, canEdit: canEdit, onChange: onChange }));
17115
+ }
16802
17116
  // Логическое значение и на чтение показываем галкой, а не словами «true»/«false» —
16803
17117
  // так же, как остальной дашборд рисует булевы атрибуты.
16804
17118
  if (type === api.AttributeType.Boolean) {
@@ -16967,6 +17281,14 @@ const compareValues = (left, right, type) => {
16967
17281
  // `numeric: true` держит строки с числами в человеческом порядке: «Дом 2» перед «Дом 10».
16968
17282
  return String(left).localeCompare(String(right), undefined, { numeric: true });
16969
17283
  };
17284
+ /**
17285
+ * Значение, по которому строка встаёт в порядок. У колонки со списком это подпись пункта, а не
17286
+ * записанное значение: пользователь видит подписи, и порядок по скрытым id выглядел бы случайным.
17287
+ */
17288
+ const getSortValue = (row, attributeName, listOptions) => {
17289
+ const value = row.properties[attributeName];
17290
+ return listOptions ? findListOption(listOptions, value)?.text ?? value : value;
17291
+ };
16970
17292
  /**
16971
17293
  * Сортировка представления. Порядок строк в черновике не меняется — он же уходит в фильтр,
16972
17294
  * а сортировка остаётся видом.
@@ -16978,15 +17300,17 @@ const sortRows = (rows, sort, schema) => {
16978
17300
  if (!sort) {
16979
17301
  return rows;
16980
17302
  }
16981
- const { type } = schema.find(({ attributeName }) => attributeName === sort.attributeName) || {};
17303
+ const { type, listOptions } = schema.find(({ attributeName }) => attributeName === sort.attributeName) || {};
16982
17304
  const direction = sort.direction === "asc" ? 1 : -1;
17305
+ // Подписи списка сравниваются как строки, какого бы типа ни было само значение.
17306
+ const compareType = listOptions ? api.AttributeType.String : type;
16983
17307
  return [...rows].sort((leftRow, rightRow) => {
16984
- const left = leftRow.properties[sort.attributeName];
16985
- const right = rightRow.properties[sort.attributeName];
17308
+ const left = getSortValue(leftRow, sort.attributeName, listOptions);
17309
+ const right = getSortValue(rightRow, sort.attributeName, listOptions);
16986
17310
  if (isEmptyValue(left) || isEmptyValue(right)) {
16987
17311
  return isEmptyValue(left) && isEmptyValue(right) ? 0 : isEmptyValue(left) ? 1 : -1;
16988
17312
  }
16989
- return compareValues(left, right, type) * direction;
17313
+ return compareValues(left, right, compareType) * direction;
16990
17314
  });
16991
17315
  };
16992
17316
  /** Клик по заголовку: по возрастанию → по убыванию → исходный порядок. */
@@ -17635,11 +17959,22 @@ const ModalTitleText = styled(uilibGl.Flex).withConfig({ displayName: "ModalTitl
17635
17959
  const ModalCloseButton = styled(uilibGl.IconButton).withConfig({ displayName: "ModalCloseButton", componentId: "sc-7ns06w" }) `
17636
17960
  margin: -0.625rem -1rem 0 1rem;
17637
17961
  `;
17962
+ /**
17963
+ * Каждого прямого ребёнка модалки `ContainerChildren` (`isMain`) оборачивает карточкой дашборда
17964
+ * `ContainerWrapper`. `noBorders` снимает с неё тень и отступ, но не фон (90% белого) и не
17965
+ * `backdrop-filter` — поверх шапки они легли бы белой размытой подложкой под каждым ребёнком.
17966
+ * Селектор прямой: вложенные карточки страниц внутри содержимого остаются как есть.
17967
+ */
17638
17968
  const ModalContent = styled(uilibGl.DialogContent).withConfig({ displayName: "ModalContent", componentId: "sc-1p8tdpb" }) `
17639
17969
  && {
17640
17970
  padding: 0 calc(${MODAL_PADDING} - ${MODAL_SCROLLBAR_WIDTH}) 0 ${MODAL_PADDING};
17641
17971
  scrollbar-gutter: stable;
17642
17972
  }
17973
+
17974
+ > ${Container} > ${ContainerWrapper} {
17975
+ background: transparent;
17976
+ backdrop-filter: none;
17977
+ }
17643
17978
  `;
17644
17979
 
17645
17980
  const isHiddenEmptyValue = ({ value, children, hideEmpty, renderElement, }) => {
@@ -18503,138 +18838,6 @@ const ChipsContainer = styled(uilibGl.Flex).withConfig({ displayName: "ChipsCont
18503
18838
  justify-content: ${({ $align }) => getJustifyContent($align)};
18504
18839
  `;
18505
18840
 
18506
- const FilterChip = styled.div.withConfig({ displayName: "FilterChip", componentId: "sc-17fod1d" }) `
18507
- display: inline-flex;
18508
- align-items: center;
18509
- gap: 0.25rem;
18510
- padding: 0.3125rem 0.5rem;
18511
- height: 1.5rem;
18512
- border-radius: 0.25rem;
18513
- background-color: ${({ $isActive, $bgColor, theme }) => $bgColor
18514
- ? $bgColor
18515
- : $isActive
18516
- ? theme.palette?.primary
18517
- : theme.palette?.elementLight};
18518
- color: ${({ $isActive, $textColor, theme }) => $textColor
18519
- ? $textColor
18520
- : $isActive
18521
- ? theme.palette?.textContrast
18522
- : theme.palette?.textSecondary};
18523
- cursor: pointer;
18524
- font-size: 0.75rem;
18525
- line-height: 0.875rem;
18526
- white-space: nowrap;
18527
- flex-shrink: 0;
18528
- transition: all 0.2s ease-in-out;
18529
- margin: 0 0.25rem 0 0;
18530
- box-sizing: border-box;
18531
-
18532
- &:hover {
18533
- background-color: ${({ $isActive, $bgColor, theme }) => $isActive
18534
- ? $bgColor || 'inherit'
18535
- : $bgColor || theme.palette?.elementDark};
18536
- }
18537
- `;
18538
- const ChipIconWrapper = styled.span.withConfig({ displayName: "ChipIconWrapper", componentId: "sc-8j3rkq" }) `
18539
- display: inline-flex;
18540
- align-items: center;
18541
- justify-content: center;
18542
- width: 0.875rem;
18543
- height: 0.875rem;
18544
- flex-shrink: 0;
18545
-
18546
- svg, img {
18547
- width: 100%;
18548
- height: 100%;
18549
- display: block;
18550
- }
18551
- `;
18552
- const ChipText = styled.span.withConfig({ displayName: "ChipText", componentId: "sc-1saygd0" }) `
18553
- overflow: hidden;
18554
- text-overflow: ellipsis;
18555
- white-space: nowrap;
18556
- ${({ $maxTextWidth }) => $maxTextWidth && `max-width: ${$maxTextWidth / 16}rem;`}
18557
- `;
18558
-
18559
- const CustomChip = ({ text, icon, color, primary, secondary, error, disabled, isActive, maxTextWidth, fontColor: customFontColor, backgroundColor: customBackgroundColor, ...props }) => {
18560
- const theme = styled.useTheme();
18561
- const { palette } = theme;
18562
- // Helper to add transparency to hex color
18563
- const addTransparency = (hex, transparency = '1f') => {
18564
- // Ensure hex starts with #
18565
- const cleanHex = hex.startsWith('#') ? hex : `#${hex}`;
18566
- return `${cleanHex}${transparency}`;
18567
- };
18568
- // Calculate colors based on priority:
18569
- // 1. customFontColor / customBackgroundColor (highest, from options)
18570
- // 2. color (from attribute)
18571
- // 3. primary/secondary/error
18572
- // 4. default theme colors
18573
- let bgColor;
18574
- let textColor;
18575
- if (isActive) {
18576
- // Priority 1: Custom colors from options
18577
- if (customFontColor) {
18578
- textColor = customFontColor;
18579
- bgColor = customBackgroundColor || addTransparency(customFontColor); // 12% opacity
18580
- }
18581
- // Priority 2: Color from attribute
18582
- else if (color) {
18583
- textColor = palette.textPrimary; // White text for active chip
18584
- bgColor = color; // Full color without transparency
18585
- }
18586
- // Priority 3: Variant colors
18587
- else if (primary) {
18588
- bgColor = theme.message.color.success; // Green
18589
- textColor = palette.textContrast;
18590
- }
18591
- else if (secondary) {
18592
- bgColor = palette.primary;
18593
- textColor = palette.textContrast;
18594
- }
18595
- else if (error) {
18596
- bgColor = theme.message.color.warning; // Yellow
18597
- textColor = palette.textPrimary;
18598
- }
18599
- // Priority 4: Default - no colors specified
18600
- else {
18601
- textColor = palette.primary;
18602
- bgColor = addTransparency(palette.primary); // palette.primary + 12%
18603
- }
18604
- }
18605
- else {
18606
- // Inactive state
18607
- if (color) {
18608
- textColor = color; // Use color for text
18609
- bgColor = addTransparency(color); // Uses default 1f (12%) transparency
18610
- }
18611
- // else: No color attribute - leave bgColor and textColor undefined
18612
- // Styled component will use fallback for inactive: bg = rgba(0,0,0,0.06), text = palette.textSecondary
18613
- }
18614
- const renderIcon = React.useCallback((iconValue) => {
18615
- if (!iconValue) {
18616
- return null;
18617
- }
18618
- // If icon doesn't contain a dot and doesn't start with /sp/, it's a built-in icon from uilib-gl
18619
- if (!iconValue.includes(".") && !iconValue.startsWith("/sp/")) {
18620
- return jsxRuntime.jsx(uilibGl.Icon, { kind: iconValue });
18621
- }
18622
- // If it's an SVG file or resourceId path, use SvgImage component
18623
- const isSvg = iconValue.endsWith(".svg") || iconValue.startsWith("/sp/resources/file/");
18624
- if (isSvg) {
18625
- const iconColor = textColor
18626
- ? textColor
18627
- : isActive
18628
- ? palette.textContrast
18629
- : palette.textSecondary;
18630
- return (jsxRuntime.jsx(SvgImage, { url: iconValue, width: 14, height: 14, fontColor: iconColor }));
18631
- }
18632
- // Otherwise, it's a regular image
18633
- return jsxRuntime.jsx("img", { src: iconValue, alt: "" });
18634
- }, [isActive, textColor, palette.textContrast, palette.textSecondary]);
18635
- return (jsxRuntime.jsxs(FilterChip, { "$isActive": isActive, "$bgColor": bgColor, "$textColor": textColor, ...props, children: [icon && jsxRuntime.jsx(ChipIconWrapper, { children: renderIcon(icon) }), jsxRuntime.jsx(ChipText, { "$maxTextWidth": maxTextWidth, children: text })] }));
18636
- };
18637
-
18638
18841
  const ChipsFilter = ({ type, filter, elementConfig, }) => {
18639
18842
  const { filters, dataSources, changeFilters } = useWidgetContext(type);
18640
18843
  const { currentPage } = useWidgetPage(type);