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