strapi-plugin-bulk-updater 1.2.13 → 1.3.1

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/README.md CHANGED
@@ -31,9 +31,14 @@ Strapi allows editing entries individually or applying bulk actions within a sin
31
31
  ## ✨ Features at a Glance
32
32
 
33
33
  - 🔍 **Dynamic Cross-Collection Discovery**: Introspects all registered content types and attributes in your Strapi v5 instance.
34
+ - 🎯 **Separate Search vs Update Attributes**: Search and filter by one attribute, relation, or nested component in Section 2, while targeting a distinct attribute to update in Section 1. Side-by-side table columns display both searched and target values simultaneously.
35
+ - 🌲 **Configurable Component Nesting Depth (Levels 1–5)**: User-controlled depth exploration in the UI (`L1` to `L5`), persistent across sessions. Fully supports recursive component schema introspection, deep population, query filtering, and safe leaf mutation preserving sibling component properties.
36
+ - 🚀 **Direct Bulk Publishing & Auto-Publish Toggle**: Directly publish draft records in bulk across multiple collections without editing values, plus an auto-publish toggle that instantly publishes updated documents for draft-enabled content types.
37
+ - 📑 **Excel-Safe Bulk CSV Export**: RFC-4180 compliant CSV export with UTF-8 BOM and CRLF line breaks for seamless opening in Microsoft Excel and Numbers, with full recursive nested component support.
38
+ - 🔍 **Interactive Pre-Update Change Preview**: Inspect real-time before-and-after diffs with impact classification (`Will Update`, `Unchanged`, `Will Skip`) prior to executing batch updates.
34
39
  - 🔎 **Searchable Combobox Attribute & Entity Picker**:
35
40
  - Instant fuzzy and substring search across all scalar fields, relations, and nested component paths.
36
- - Interactive visual badges for relation targets (`relation ➔ Target`) and components.
41
+ - Interactive visual badges for relation targets (`relation ➔ Target`) and component nesting levels (`L1 component`, `L2 component`).
37
42
  - 🔗 **Full Relational Field Support**:
38
43
  - Supports `manyToOne`, `oneToOne`, `oneToMany`, and `manyToMany` relations.
39
44
  - Interactive searchable target entity dropdown populated directly from target collection records.
@@ -57,9 +62,6 @@ Strapi allows editing entries individually or applying bulk actions within a sin
57
62
  - **Convention Auto-Discovery**: Seamlessly detects and mirrors custom app collections (`api::audit-log.audit-log`).
58
63
  - **Strapi Enterprise Bridge**: Bridges events directly to native Strapi Enterprise Audit Logs (`admin::audit-logs`).
59
64
  - **Deterministic Rollback**: Restores exact prior values and relationship links using Strapi v5 Document Service.
60
- - 🧩 **Deep Nested Component Support**:
61
- - Full dot-notation introspection (e.g. `metaFields.metaDescription`, `defaultSeo.metaTitle`) in attribute dropdown and table views.
62
- - Non-destructive deep merging preserves sibling component properties during bulk updates and rollbacks.
63
65
  - ⚡ **Server-Side Filter & Full-Text Search Pushdown**:
64
66
  - Pushes text searches and attribute filters (`equals`, `contains`, `empty`, `starts_with`) directly to the database.
65
67
  - Automatically hides non-matching records and pre-selects matching rows with real-time match counters.
@@ -246,6 +248,10 @@ All routes are mounted under `/bulk-updater` and protected by Strapi Admin authe
246
248
  | `DELETE` | `/bulk-updater/validation-rules/:id` | Deletes a validation rule. |
247
249
  | `POST` | `/bulk-updater/validation-rules/:id/toggle` | Toggles rule active status. |
248
250
  | `POST` | `/bulk-updater/validation-rules/:id/scan` | Scans all existing records in target content types for violations. |
251
+ | `POST` | `/bulk-updater/preview` | Generates a dry-run diff preview across target entries before update. |
252
+ | `POST` | `/bulk-updater/publish` | Bulk publishes selected draft entries across multiple collections. |
253
+ | `POST` | `/bulk-updater/export-selected` | Exports selected entries across collections to Excel-compatible CSV. |
254
+ | `GET` | `/bulk-updater/collections/:uid/entries` | Paginated and filtered collection entries with search attribute pushdown. |
249
255
  | `GET` | `/bulk-updater/csv/template/:uid` | Generates a blank CSV template with content type headers. |
250
256
  | `GET` | `/bulk-updater/csv/export/:uid` | Exports all records for a collection type as CSV. |
251
257
  | `GET` | `/bulk-updater/csv/export-violations/:ruleId` | Exports only records violating the specified rule as CSV. |
@@ -1458,7 +1458,7 @@ function getNestedValue(obj, path) {
1458
1458
  if (!path.includes(".")) return obj[path];
1459
1459
  return path.split(".").reduce((acc, part) => acc && typeof acc === "object" ? acc[part] : void 0, obj);
1460
1460
  }
1461
- function getAvailableColumnsForCollection(col, selectedAttribute) {
1461
+ function getAvailableColumnsForCollection(col, selectedAttribute, searchAttribute) {
1462
1462
  const cols = [
1463
1463
  {
1464
1464
  key: "select",
@@ -1475,7 +1475,7 @@ function getAvailableColumnsForCollection(col, selectedAttribute) {
1475
1475
  ];
1476
1476
  const candidateAttrs = col.attributes || [];
1477
1477
  candidateAttrs.forEach((attr) => {
1478
- if (attr === "name" || attr === "title" || attr === selectedAttribute) {
1478
+ if (attr === "name" || attr === "title" || attr === selectedAttribute || searchAttribute && attr === searchAttribute) {
1479
1479
  return;
1480
1480
  }
1481
1481
  const formattedLabel = attr.includes(".") ? attr.split(".").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(" > ") : attr.charAt(0).toUpperCase() + attr.slice(1);
@@ -1486,9 +1486,19 @@ function getAvailableColumnsForCollection(col, selectedAttribute) {
1486
1486
  defaultVisible: ["department", "email", "location"].includes(attr)
1487
1487
  });
1488
1488
  });
1489
+ if (searchAttribute && searchAttribute !== selectedAttribute) {
1490
+ const formattedSearchLabel = searchAttribute.includes(".") ? searchAttribute.split(".").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(" > ") : searchAttribute;
1491
+ cols.push({
1492
+ key: "search_attr",
1493
+ label: `Search: "${formattedSearchLabel}"`,
1494
+ sortable: !col.isSingleType && col.attributes.includes(searchAttribute),
1495
+ defaultVisible: true
1496
+ });
1497
+ }
1498
+ const targetLabel = searchAttribute && searchAttribute !== selectedAttribute ? `Update Target: "${selectedAttribute}"` : `Current "${selectedAttribute}"`;
1489
1499
  cols.push({
1490
1500
  key: "target_attr",
1491
- label: `Current "${selectedAttribute}"`,
1501
+ label: targetLabel,
1492
1502
  sortable: !col.isSingleType && col.attributes.includes(selectedAttribute),
1493
1503
  defaultVisible: true
1494
1504
  });
@@ -1500,17 +1510,19 @@ function getAvailableColumnsForCollection(col, selectedAttribute) {
1500
1510
  });
1501
1511
  return cols;
1502
1512
  }
1503
- function renderHeaderCell(colDef, col, currentSort, selectedAttribute, onSort) {
1513
+ function renderHeaderCell(colDef, col, currentSort, selectedAttribute, searchAttribute, onSort) {
1504
1514
  if (colDef.key === "select") {
1505
1515
  return /* @__PURE__ */ jsxRuntime.jsx(designSystem.Th, { style: { width: 48 }, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "sigma", children: "Select" }) }, "select");
1506
1516
  }
1507
- const isCurrentSorted = colDef.key === "identifier" && (currentSort.field === "name" || currentSort.field === "title") || colDef.key === "target_attr" && currentSort.field === selectedAttribute || colDef.key.startsWith("attr_") && currentSort.field === colDef.key.replace("attr_", "");
1517
+ const isCurrentSorted = colDef.key === "identifier" && (currentSort.field === "name" || currentSort.field === "title") || colDef.key === "search_attr" && currentSort.field === searchAttribute || colDef.key === "target_attr" && currentSort.field === selectedAttribute || colDef.key.startsWith("attr_") && currentSort.field === colDef.key.replace("attr_", "");
1508
1518
  const sortArrow = isCurrentSorted ? currentSort.order === "asc" ? "▲" : "▼" : "⇅";
1509
1519
  const handleClick = () => {
1510
1520
  if (!colDef.sortable) return;
1511
1521
  if (colDef.key === "identifier") {
1512
1522
  const field = col.attributes.includes("name") ? "name" : col.attributes.includes("title") ? "title" : "createdAt";
1513
1523
  onSort(col.uid, field);
1524
+ } else if (colDef.key === "search_attr") {
1525
+ onSort(col.uid, searchAttribute);
1514
1526
  } else if (colDef.key === "target_attr") {
1515
1527
  onSort(col.uid, selectedAttribute);
1516
1528
  } else if (colDef.key.startsWith("attr_")) {
@@ -1546,7 +1558,7 @@ function formatDisplayValue(val) {
1546
1558
  }
1547
1559
  return String(val);
1548
1560
  }
1549
- function renderCellContent(colDef, entry, col, selectedAttribute, isMatch, hasAttr, isSelected, onToggleSelect) {
1561
+ function renderCellContent(colDef, entry, col, selectedAttribute, searchAttribute, isMatch, hasAttr, isSelected, onToggleSelect) {
1550
1562
  switch (colDef.key) {
1551
1563
  case "select":
1552
1564
  return /* @__PURE__ */ jsxRuntime.jsx(
@@ -1563,6 +1575,27 @@ function renderCellContent(colDef, entry, col, selectedAttribute, isMatch, hasAt
1563
1575
  entry.email && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral500", display: "block", children: entry.email })
1564
1576
  ] });
1565
1577
  }
1578
+ case "search_attr": {
1579
+ const val = getNestedValue(entry, searchAttribute);
1580
+ const displayStr = formatDisplayValue(val);
1581
+ const isUnset = displayStr === "(empty / not set)" || displayStr === "(empty / unlinked)";
1582
+ return /* @__PURE__ */ jsxRuntime.jsx(
1583
+ designSystem.Typography,
1584
+ {
1585
+ textColor: isUnset ? "neutral500" : "primary700",
1586
+ fontWeight: "semiBold",
1587
+ style: {
1588
+ fontStyle: isUnset ? "italic" : "normal",
1589
+ display: "inline-block",
1590
+ maxWidth: 260,
1591
+ overflow: "hidden",
1592
+ textOverflow: "ellipsis",
1593
+ whiteSpace: "nowrap"
1594
+ },
1595
+ children: displayStr
1596
+ }
1597
+ );
1598
+ }
1566
1599
  case "target_attr": {
1567
1600
  const val = getNestedValue(entry, selectedAttribute);
1568
1601
  const displayStr = formatDisplayValue(val);
@@ -1687,10 +1720,34 @@ const BulkUpdater = () => {
1687
1720
  };
1688
1721
  const [useRegex, setUseRegex] = React.useState(false);
1689
1722
  const [regexPattern, setRegexPattern] = React.useState("");
1723
+ const DEPTH_STORAGE_KEY = "strapi_bulk_updater_component_depth";
1724
+ const [componentDepth, setComponentDepth] = React.useState(() => {
1725
+ try {
1726
+ const stored = localStorage.getItem(DEPTH_STORAGE_KEY);
1727
+ if (stored) {
1728
+ const val = Number(stored);
1729
+ if (val >= 1 && val <= 5) return val;
1730
+ }
1731
+ } catch {
1732
+ }
1733
+ return 3;
1734
+ });
1735
+ const handleComponentDepthChange = (depth) => {
1736
+ const capped = Math.min(5, Math.max(1, depth));
1737
+ setComponentDepth(capped);
1738
+ try {
1739
+ localStorage.setItem(DEPTH_STORAGE_KEY, String(capped));
1740
+ } catch {
1741
+ }
1742
+ };
1743
+ const [searchAttribute, setSearchAttribute] = React.useState("");
1744
+ const [searchAttributeInput, setSearchAttributeInput] = React.useState("");
1745
+ const [attributesMeta, setAttributesMeta] = React.useState({});
1746
+ const effectiveSearchAttribute = searchAttribute || selectedAttribute;
1690
1747
  const [filterOperator, setFilterOperator] = React.useState("none");
1691
1748
  const [filterQuery, setFilterQuery] = React.useState("");
1692
1749
  const [entrySearch, setEntrySearch] = React.useState("");
1693
- const isFilterActive = filterOperator !== "none" && (["empty", "not_empty"].includes(filterOperator) || Boolean(filterQuery.trim())) || Boolean(entrySearch.trim());
1750
+ const isFilterActive = Boolean(searchAttribute && searchAttribute !== selectedAttribute) || filterOperator !== "none" && (["empty", "not_empty"].includes(filterOperator) || Boolean(filterQuery.trim())) || Boolean(entrySearch.trim());
1694
1751
  const [lastResults, setLastResults] = React.useState(null);
1695
1752
  const [updateSummary, setUpdateSummary] = React.useState(null);
1696
1753
  const [collectionPages, setCollectionPages] = React.useState({});
@@ -1770,6 +1827,9 @@ const BulkUpdater = () => {
1770
1827
  if (data.relationsMeta) {
1771
1828
  setRelationsMeta(data.relationsMeta);
1772
1829
  }
1830
+ if (data.attributesMeta) {
1831
+ setAttributesMeta(data.attributesMeta);
1832
+ }
1773
1833
  let storedAttr = null;
1774
1834
  try {
1775
1835
  storedAttr = localStorage.getItem(LAST_ATTR_KEY);
@@ -1817,7 +1877,7 @@ const BulkUpdater = () => {
1817
1877
  setLoading(false);
1818
1878
  }
1819
1879
  };
1820
- const loadCollectionPage = async (colUid, targetPage, customPageSize, customSortField, customSortOrder, overrideFilterOp, overrideFilterQuery, overrideEntrySearch, autoSelectReturned = true) => {
1880
+ const loadCollectionPage = async (colUid, targetPage, customPageSize, customSortField, customSortOrder, overrideFilterOp, overrideFilterQuery, overrideEntrySearch, autoSelectReturned = true, overrideSearchAttr) => {
1821
1881
  const currentPageState = collectionPages[colUid];
1822
1882
  const size = customPageSize || currentPageState?.pageSize || defaultPageSize;
1823
1883
  const currentSort = collectionSorts[colUid] || { field: "createdAt", order: "desc" };
@@ -1826,6 +1886,7 @@ const BulkUpdater = () => {
1826
1886
  const op = overrideFilterOp !== void 0 ? overrideFilterOp : filterOperator;
1827
1887
  const q = overrideFilterQuery !== void 0 ? overrideFilterQuery : filterQuery;
1828
1888
  const s = overrideEntrySearch !== void 0 ? overrideEntrySearch : entrySearch;
1889
+ const searchAttr = overrideSearchAttr !== void 0 ? overrideSearchAttr : searchAttribute;
1829
1890
  setCollectionPages((prev) => ({
1830
1891
  ...prev,
1831
1892
  [colUid]: {
@@ -1839,6 +1900,9 @@ const BulkUpdater = () => {
1839
1900
  pageSize: String(size)
1840
1901
  });
1841
1902
  if (selectedAttribute) params.set("attribute", selectedAttribute);
1903
+ if (searchAttr && searchAttr !== selectedAttribute) {
1904
+ params.set("searchAttribute", searchAttr);
1905
+ }
1842
1906
  if (op && op !== "none") {
1843
1907
  params.set("filterOperator", op);
1844
1908
  if (q) params.set("filterQuery", q);
@@ -1868,7 +1932,7 @@ const BulkUpdater = () => {
1868
1932
  loading: false
1869
1933
  }
1870
1934
  }));
1871
- const isFilterActiveNow = op !== "none" && (["empty", "not_empty"].includes(op) || Boolean(q.trim())) || Boolean(s.trim());
1935
+ const isFilterActiveNow = Boolean(searchAttr && searchAttr !== selectedAttribute) || op !== "none" && (["empty", "not_empty"].includes(op) || Boolean(q.trim())) || Boolean(s.trim());
1872
1936
  if (isFilterActiveNow && autoSelectReturned) {
1873
1937
  setSelectedEntries((prev) => {
1874
1938
  const next = { ...prev };
@@ -1955,13 +2019,14 @@ const BulkUpdater = () => {
1955
2019
  }
1956
2020
  const timer = setTimeout(() => {
1957
2021
  if (collections.length > 0) {
1958
- const isFilterActiveNow = filterOperator !== "none" && (["empty", "not_empty"].includes(filterOperator) || Boolean(filterQuery.trim())) || Boolean(entrySearch.trim());
2022
+ const isFilterActiveNow = Boolean(searchAttribute && searchAttribute !== selectedAttribute) || filterOperator !== "none" && (["empty", "not_empty"].includes(filterOperator) || Boolean(filterQuery.trim())) || Boolean(entrySearch.trim());
1959
2023
  if (isFilterActiveNow) {
1960
2024
  setSelectedEntries({});
1961
2025
  }
2026
+ const targetSearchAttr = searchAttribute || selectedAttribute;
1962
2027
  const targetCols = collections.filter((col) => {
1963
2028
  if (visibleTypeUids[col.uid] === false) return false;
1964
- if (filterOperator !== "none" && !col.attributes.includes(selectedAttribute)) return false;
2029
+ if (filterOperator !== "none" && targetSearchAttr && !col.attributes.includes(targetSearchAttr)) return false;
1965
2030
  return true;
1966
2031
  });
1967
2032
  targetCols.forEach((col) => {
@@ -1974,13 +2039,14 @@ const BulkUpdater = () => {
1974
2039
  filterOperator,
1975
2040
  filterQuery,
1976
2041
  entrySearch,
1977
- true
2042
+ true,
2043
+ searchAttribute
1978
2044
  );
1979
2045
  });
1980
2046
  }
1981
2047
  }, 350);
1982
2048
  return () => clearTimeout(timer);
1983
- }, [filterOperator, filterQuery, entrySearch]);
2049
+ }, [searchAttribute, filterOperator, filterQuery, entrySearch]);
1984
2050
  const KEY_SEP = "@@@";
1985
2051
  const handleAttributeChange = (newAttr) => {
1986
2052
  if (newAttr !== selectedAttribute) {
@@ -2049,39 +2115,119 @@ const BulkUpdater = () => {
2049
2115
  setVisibleTypeUids(newVis);
2050
2116
  };
2051
2117
  const [attributeSearch, setAttributeSearch] = React.useState("");
2118
+ const [searchAttributeSearch, setSearchAttributeSearch] = React.useState("");
2119
+ const getAttrDepth = (attr) => attributesMeta[attr]?.depth ?? (attr.includes(".") ? attr.split(".").length - 1 : 0);
2120
+ const depthFilteredAttributes = React.useMemo(() => {
2121
+ return attributes.filter((attr) => getAttrDepth(attr) <= componentDepth);
2122
+ }, [attributes, componentDepth, attributesMeta]);
2052
2123
  const filteredAttributes = React.useMemo(() => {
2053
2124
  if (!attributeSearch.trim()) {
2054
- return attributes;
2125
+ return depthFilteredAttributes;
2055
2126
  }
2056
2127
  const q = attributeSearch.toLowerCase().trim();
2057
2128
  const cleanQ = q.replace(/[^a-z0-9]/g, "");
2058
- return attributes.filter((attr) => {
2129
+ return depthFilteredAttributes.filter((attr) => {
2059
2130
  const lower = attr.toLowerCase();
2060
2131
  const cleanLower = lower.replace(/[^a-z0-9]/g, "");
2061
2132
  const rel = relationsMeta[attr];
2062
2133
  const relTarget = (rel?.targetDisplayName || "").toLowerCase();
2063
2134
  return lower.includes(q) || cleanLower.includes(cleanQ) || relTarget.includes(q);
2064
2135
  });
2065
- }, [attributes, attributeSearch, relationsMeta]);
2136
+ }, [depthFilteredAttributes, attributeSearch, relationsMeta]);
2137
+ const filteredSearchAttributes = React.useMemo(() => {
2138
+ if (!searchAttributeSearch.trim()) {
2139
+ return depthFilteredAttributes;
2140
+ }
2141
+ const q = searchAttributeSearch.toLowerCase().trim();
2142
+ const cleanQ = q.replace(/[^a-z0-9]/g, "");
2143
+ return depthFilteredAttributes.filter((attr) => {
2144
+ const lower = attr.toLowerCase();
2145
+ const cleanLower = lower.replace(/[^a-z0-9]/g, "");
2146
+ const rel = relationsMeta[attr];
2147
+ const relTarget = (rel?.targetDisplayName || "").toLowerCase();
2148
+ return lower.includes(q) || cleanLower.includes(cleanQ) || relTarget.includes(q);
2149
+ });
2150
+ }, [depthFilteredAttributes, searchAttributeSearch, relationsMeta]);
2066
2151
  const memoizedComboboxOptions = React.useMemo(() => {
2067
2152
  if (filteredAttributes.length === 0) {
2068
2153
  return /* @__PURE__ */ jsxRuntime.jsx(designSystem.ComboboxOption, { value: "__none__", disabled: true, textValue: "No matching attributes found", children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Typography, { variant: "pi", textColor: "neutral600", children: [
2069
2154
  'No matching attributes found for "',
2070
2155
  attributeSearch,
2071
- '"'
2156
+ '" (Depth L',
2157
+ componentDepth,
2158
+ ")"
2072
2159
  ] }) });
2073
2160
  }
2074
2161
  return filteredAttributes.map((attr) => {
2075
2162
  const rel = relationsMeta[attr];
2163
+ const depth = getAttrDepth(attr);
2076
2164
  return /* @__PURE__ */ jsxRuntime.jsx(designSystem.ComboboxOption, { value: attr, textValue: attr, children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { justifyContent: "space-between", alignItems: "center", width: "100%", gap: 2, children: [
2077
2165
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "omega", fontWeight: attr === selectedAttribute ? "bold" : "regular", children: attr }),
2078
2166
  rel ? /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Badge, { backgroundColor: "secondary100", textColor: "secondary700", size: "S", children: [
2079
2167
  "relation ➔ ",
2080
2168
  rel.targetDisplayName
2081
- ] }) : attr.includes(".") ? /* @__PURE__ */ jsxRuntime.jsx(designSystem.Badge, { backgroundColor: "neutral150", textColor: "neutral700", size: "S", children: "component" }) : null
2169
+ ] }) : depth > 0 ? /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Badge, { backgroundColor: depth === 1 ? "neutral150" : depth === 2 ? "primary100" : "secondary100", textColor: depth === 1 ? "neutral700" : depth === 2 ? "primary700" : "secondary700", size: "S", children: [
2170
+ "L",
2171
+ depth,
2172
+ " component"
2173
+ ] }) : null
2082
2174
  ] }) }, attr);
2083
2175
  });
2084
- }, [filteredAttributes, relationsMeta, selectedAttribute, attributeSearch]);
2176
+ }, [filteredAttributes, relationsMeta, selectedAttribute, attributeSearch, componentDepth, attributesMeta]);
2177
+ const memoizedSearchComboboxOptions = React.useMemo(() => {
2178
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2179
+ /* @__PURE__ */ jsxRuntime.jsx(
2180
+ designSystem.ComboboxOption,
2181
+ {
2182
+ value: "__default__",
2183
+ textValue: `Same as Update Attribute (${selectedAttribute || "None"})`,
2184
+ children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { justifyContent: "space-between", alignItems: "center", width: "100%", gap: 2, children: [
2185
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Typography, { variant: "omega", fontStyle: "italic", textColor: "neutral600", children: [
2186
+ "-- Same as Update Attribute (",
2187
+ selectedAttribute || "None",
2188
+ ") --"
2189
+ ] }),
2190
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Badge, { backgroundColor: "neutral150", textColor: "neutral600", size: "S", children: "Default" })
2191
+ ] })
2192
+ }
2193
+ ),
2194
+ filteredSearchAttributes.map((attr) => {
2195
+ const rel = relationsMeta[attr];
2196
+ const depth = getAttrDepth(attr);
2197
+ return /* @__PURE__ */ jsxRuntime.jsx(designSystem.ComboboxOption, { value: attr, textValue: attr, children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { justifyContent: "space-between", alignItems: "center", width: "100%", gap: 2, children: [
2198
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "omega", fontWeight: attr === searchAttribute ? "bold" : "regular", children: attr }),
2199
+ rel ? /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Badge, { backgroundColor: "secondary100", textColor: "secondary700", size: "S", children: [
2200
+ "relation ➔ ",
2201
+ rel.targetDisplayName
2202
+ ] }) : depth > 0 ? /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Badge, { backgroundColor: depth === 1 ? "neutral150" : depth === 2 ? "primary100" : "secondary100", textColor: depth === 1 ? "neutral700" : depth === 2 ? "primary700" : "secondary700", size: "S", children: [
2203
+ "L",
2204
+ depth,
2205
+ " component"
2206
+ ] }) : null
2207
+ ] }) }, `search_${attr}`);
2208
+ })
2209
+ ] });
2210
+ }, [filteredSearchAttributes, relationsMeta, searchAttribute, selectedAttribute, componentDepth, attributesMeta]);
2211
+ const handleSearchAttributeChange = (newAttr) => {
2212
+ const finalAttr = newAttr === "__default__" ? "" : newAttr;
2213
+ setSearchAttribute(finalAttr);
2214
+ if (collections.length > 0) {
2215
+ collections.forEach((col) => {
2216
+ loadCollectionPage(
2217
+ col.uid,
2218
+ 1,
2219
+ void 0,
2220
+ void 0,
2221
+ void 0,
2222
+ filterOperator,
2223
+ filterQuery,
2224
+ entrySearch,
2225
+ true,
2226
+ finalAttr
2227
+ );
2228
+ });
2229
+ }
2230
+ };
2085
2231
  const handleGlobalPageSizeChange = (newSize) => {
2086
2232
  const capped = Math.min(500, Math.max(1, newSize));
2087
2233
  setDefaultPageSize(capped);
@@ -2172,12 +2318,14 @@ const BulkUpdater = () => {
2172
2318
  setSelectedEntries(updated);
2173
2319
  };
2174
2320
  const clearFilter = () => {
2321
+ setSearchAttribute("");
2322
+ setSearchAttributeSearch("");
2175
2323
  setFilterOperator("none");
2176
2324
  setFilterQuery("");
2177
2325
  setEntrySearch("");
2178
2326
  setSelectedEntries({});
2179
2327
  collections.forEach((col) => {
2180
- loadCollectionPage(col.uid, 1, void 0, void 0, void 0, "none", "", "", false);
2328
+ loadCollectionPage(col.uid, 1, void 0, void 0, void 0, "none", "", "", false, "");
2181
2329
  });
2182
2330
  };
2183
2331
  const isCollectionFullySelected = (col) => {
@@ -2973,7 +3121,29 @@ const BulkUpdater = () => {
2973
3121
  marginBottom: 6,
2974
3122
  style: { width: "100%", maxWidth: "100%", minWidth: 0, boxSizing: "border-box" },
2975
3123
  children: [
2976
- /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "delta", tag: "h2", marginBottom: 4, fontWeight: "semiBold", children: "1. Configure Attribute Update" }),
3124
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { justifyContent: "space-between", alignItems: "center", marginBottom: 4, wrap: "wrap", gap: 3, children: [
3125
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { children: [
3126
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "delta", tag: "h2", fontWeight: "semiBold", children: "1. Configure Attribute Update" }),
3127
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral600", children: "Select the target attribute to update across collections." })
3128
+ ] }),
3129
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { gap: 2, alignItems: "center", wrap: "wrap", children: [
3130
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral700", fontWeight: "bold", children: "Component Nesting Depth:" }),
3131
+ [1, 2, 3, 4, 5].map((lvl) => /* @__PURE__ */ jsxRuntime.jsxs(
3132
+ designSystem.Button,
3133
+ {
3134
+ variant: componentDepth === lvl ? "default" : "tertiary",
3135
+ size: "S",
3136
+ onClick: () => handleComponentDepthChange(lvl),
3137
+ title: `Discover nested component attributes up to Level ${lvl}`,
3138
+ children: [
3139
+ "Level ",
3140
+ lvl
3141
+ ]
3142
+ },
3143
+ `depth_cfg_${lvl}`
3144
+ ))
3145
+ ] })
3146
+ ] }),
2977
3147
  /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { gap: 4, alignItems: "flex-end", wrap: "wrap", children: [
2978
3148
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { style: { minWidth: 280, flex: 1, maxWidth: 440 }, children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Field.Root, { children: [
2979
3149
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Label, { children: "Attribute to Update" }),
@@ -3304,26 +3474,83 @@ const BulkUpdater = () => {
3304
3474
  style: { width: "100%", maxWidth: "100%", boxSizing: "border-box" },
3305
3475
  children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { direction: "column", gap: 3, children: [
3306
3476
  /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { justifyContent: "space-between", alignItems: "center", wrap: "wrap", gap: 2, children: [
3307
- /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { gap: 2, alignItems: "center", children: [
3477
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { gap: 2, alignItems: "center", wrap: "wrap", children: [
3308
3478
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "omega", fontWeight: "bold", children: "Search & Filter Targets:" }),
3309
3479
  isFilterActive && /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Badge, { backgroundColor: "primary100", textColor: "primary700", children: [
3310
3480
  matchingEntriesCount,
3311
3481
  " matching ",
3312
3482
  matchingEntriesCount === 1 ? "entry" : "entries",
3313
3483
  " found in database"
3484
+ ] }),
3485
+ searchAttribute && searchAttribute !== selectedAttribute && /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Badge, { backgroundColor: "secondary100", textColor: "secondary700", children: [
3486
+ "Searching by: ",
3487
+ searchAttribute,
3488
+ " → Target to update: ",
3489
+ selectedAttribute
3314
3490
  ] })
3315
3491
  ] }),
3316
- isFilterActive && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Button, { variant: "ghost", size: "S", onClick: clearFilter, children: "Clear Search & Filter" })
3492
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { gap: 2, alignItems: "center", wrap: "wrap", children: [
3493
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { gap: 1, alignItems: "center", children: [
3494
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral600", children: "Depth:" }),
3495
+ [1, 2, 3, 4, 5].map((lvl) => /* @__PURE__ */ jsxRuntime.jsxs(
3496
+ designSystem.Button,
3497
+ {
3498
+ variant: componentDepth === lvl ? "default" : "tertiary",
3499
+ size: "S",
3500
+ onClick: () => handleComponentDepthChange(lvl),
3501
+ title: `Show component attributes up to Level ${lvl}`,
3502
+ style: { minWidth: 26, padding: "2px 6px" },
3503
+ children: [
3504
+ "L",
3505
+ lvl
3506
+ ]
3507
+ },
3508
+ `s2_depth_${lvl}`
3509
+ ))
3510
+ ] }),
3511
+ isFilterActive && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Button, { variant: "ghost", size: "S", onClick: clearFilter, children: "Clear Search & Filter" })
3512
+ ] })
3317
3513
  ] }),
3318
3514
  /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { gap: 3, alignItems: "flex-end", wrap: "wrap", children: [
3319
- /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { style: { width: 220, minWidth: 180 }, children: /* @__PURE__ */ jsxRuntime.jsxs(
3515
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { style: { width: 280, minWidth: 230 }, children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Field.Root, { children: [
3516
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Label, { children: "Search Attribute" }),
3517
+ /* @__PURE__ */ jsxRuntime.jsx(
3518
+ designSystem.Combobox,
3519
+ {
3520
+ autocomplete: "none",
3521
+ placeholder: searchAttribute || `Same as Update (${selectedAttribute || "None"})`,
3522
+ value: searchAttribute || "__default__",
3523
+ clearLabel: "Reset search attribute",
3524
+ onClear: () => {
3525
+ handleSearchAttributeChange("");
3526
+ setSearchAttributeSearch("");
3527
+ },
3528
+ onInputChange: (e) => {
3529
+ setSearchAttributeSearch(e.target.value);
3530
+ },
3531
+ onOpenChange: (isOpen) => {
3532
+ if (!isOpen) {
3533
+ setSearchAttributeSearch("");
3534
+ }
3535
+ },
3536
+ onChange: (val) => {
3537
+ if (val && typeof val === "string" && val !== "__none__") {
3538
+ handleSearchAttributeChange(val);
3539
+ setSearchAttributeSearch("");
3540
+ }
3541
+ },
3542
+ children: memoizedSearchComboboxOptions
3543
+ }
3544
+ )
3545
+ ] }) }),
3546
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { style: { width: 200, minWidth: 170 }, children: /* @__PURE__ */ jsxRuntime.jsxs(
3320
3547
  designSystem.SingleSelect,
3321
3548
  {
3322
- label: selectedAttribute ? `Filter condition for "${selectedAttribute}"` : "Filter condition",
3549
+ label: effectiveSearchAttribute ? `Condition for "${effectiveSearchAttribute}"` : "Filter condition",
3323
3550
  value: filterOperator,
3324
3551
  onChange: (val) => setFilterOperator(String(val)),
3325
3552
  children: [
3326
- /* @__PURE__ */ jsxRuntime.jsx(designSystem.SingleSelectOption, { value: "none", children: "Show All (No Attribute Filter)" }),
3553
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.SingleSelectOption, { value: "none", children: "Show All (No Value Filter)" }),
3327
3554
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.SingleSelectOption, { value: "empty", children: "Is Empty or Null" }),
3328
3555
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.SingleSelectOption, { value: "not_empty", children: "Is Not Empty" }),
3329
3556
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.SingleSelectOption, { value: "equals", children: "Equals Exact Text" }),
@@ -3332,16 +3559,16 @@ const BulkUpdater = () => {
3332
3559
  ]
3333
3560
  }
3334
3561
  ) }),
3335
- (filterOperator === "equals" || filterOperator === "contains" || filterOperator === "starts_with") && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { style: { flex: 1, minWidth: 200 }, children: /* @__PURE__ */ jsxRuntime.jsx(
3562
+ (filterOperator === "equals" || filterOperator === "contains" || filterOperator === "starts_with") && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { style: { flex: 1, minWidth: 180 }, children: /* @__PURE__ */ jsxRuntime.jsx(
3336
3563
  designSystem.TextInput,
3337
3564
  {
3338
- label: selectedAttribute ? `Value for "${selectedAttribute}"` : "Value to match",
3565
+ label: effectiveSearchAttribute ? `Value for "${effectiveSearchAttribute}"` : "Value to match",
3339
3566
  placeholder: "Type value to match...",
3340
3567
  value: filterQuery,
3341
3568
  onChange: (e) => setFilterQuery(e.target.value)
3342
3569
  }
3343
3570
  ) }),
3344
- /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { style: { flex: 1, minWidth: 200 }, children: /* @__PURE__ */ jsxRuntime.jsx(
3571
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { style: { flex: 1, minWidth: 180 }, children: /* @__PURE__ */ jsxRuntime.jsx(
3345
3572
  designSystem.TextInput,
3346
3573
  {
3347
3574
  label: "Full-text Entry Search",
@@ -3539,11 +3766,11 @@ const BulkUpdater = () => {
3539
3766
  const pageSize = pageState ? pageState.pageSize : defaultPageSize;
3540
3767
  const hasAttr = selectedAttribute ? col.attributes.includes(selectedAttribute) : false;
3541
3768
  const visibleEntries = getVisibleEntries(col);
3542
- selectedAttribute ? entries.filter((e) => entryMatchesFilter(e, selectedAttribute)).length : 0;
3769
+ selectedAttribute ? entries.filter((e) => entryMatchesFilter(e, effectiveSearchAttribute)).length : 0;
3543
3770
  const isFullySelected = isCollectionFullySelected(col);
3544
3771
  const isPartiallySelected = isCollectionPartiallySelected(col);
3545
3772
  const currentSort = collectionSorts[col.uid] || { field: "createdAt", order: "desc" };
3546
- const availableColumns = getAvailableColumnsForCollection(col, selectedAttribute);
3773
+ const availableColumns = getAvailableColumnsForCollection(col, selectedAttribute, searchAttribute);
3547
3774
  const savedColKeys = collectionColumnVisibility[col.uid];
3548
3775
  const activeColumns = savedColKeys ? availableColumns.filter((c) => c.isPermanent || savedColKeys.includes(c.key)) : availableColumns.filter((c) => c.defaultVisible);
3549
3776
  return /* @__PURE__ */ jsxRuntime.jsxs(
@@ -3753,7 +3980,7 @@ const BulkUpdater = () => {
3753
3980
  ),
3754
3981
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { className: "bulk-updater-table-container", style: { width: "100%", maxWidth: "100%", minWidth: 0, overflowX: "auto" }, children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Table, { colCount: activeColumns.length, rowCount: visibleEntries.length + 1, style: { width: "100%" }, children: [
3755
3982
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Thead, { children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Tr, { children: activeColumns.map(
3756
- (colDef) => renderHeaderCell(colDef, col, currentSort, selectedAttribute, handleHeaderSortClick)
3983
+ (colDef) => renderHeaderCell(colDef, col, currentSort, selectedAttribute, searchAttribute, handleHeaderSortClick)
3757
3984
  ) }) }),
3758
3985
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Tbody, { children: visibleEntries.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx(designSystem.Tr, { children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Td, { colSpan: activeColumns.length, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { padding: 4, style: { textAlign: "center" }, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral500", children: "No entries in this content type match the active filter criteria." }) }) }) }) : visibleEntries.map((entry) => {
3759
3986
  const key = `${col.uid}${KEY_SEP}${entry.documentId}`;
@@ -3771,6 +3998,7 @@ const BulkUpdater = () => {
3771
3998
  entry,
3772
3999
  col,
3773
4000
  selectedAttribute,
4001
+ searchAttribute,
3774
4002
  isMatch,
3775
4003
  hasAttr,
3776
4004
  isSelected,
@@ -4744,4 +4972,4 @@ exports.default = BulkUpdater;
4744
4972
  exports.formatDisplayValue = formatDisplayValue;
4745
4973
  exports.getAvailableColumnsForCollection = getAvailableColumnsForCollection;
4746
4974
  exports.getNestedValue = getNestedValue;
4747
- //# sourceMappingURL=BulkUpdater-CWJYmuGU.js.map
4975
+ //# sourceMappingURL=BulkUpdater-CRvwX326.js.map