@camstack/ui-library 1.2.46 → 1.2.48
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/composites/data-table.d.ts +17 -1
- package/dist/composites/device-selector-picker.d.ts +37 -0
- package/dist/composites/index.d.ts +2 -0
- package/dist/composites/scope-picker.d.ts +7 -2
- package/dist/index.cjs +553 -91
- package/dist/index.js +549 -93
- package/dist/primitives/dialog.d.ts +14 -0
- package/dist/primitives/tabs.d.ts +17 -0
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -10362,6 +10362,29 @@ var Lightbulb = createLucideIcon("lightbulb", [
|
|
|
10362
10362
|
* This source code is licensed under the ISC license.
|
|
10363
10363
|
* See the LICENSE file in the root directory of this source tree.
|
|
10364
10364
|
*/
|
|
10365
|
+
var Link2 = createLucideIcon("link-2", [
|
|
10366
|
+
["path", {
|
|
10367
|
+
d: "M9 17H7A5 5 0 0 1 7 7h2",
|
|
10368
|
+
key: "8i5ue5"
|
|
10369
|
+
}],
|
|
10370
|
+
["path", {
|
|
10371
|
+
d: "M15 7h2a5 5 0 1 1 0 10h-2",
|
|
10372
|
+
key: "1b9ql8"
|
|
10373
|
+
}],
|
|
10374
|
+
["line", {
|
|
10375
|
+
x1: "8",
|
|
10376
|
+
x2: "16",
|
|
10377
|
+
y1: "12",
|
|
10378
|
+
y2: "12",
|
|
10379
|
+
key: "1jonct"
|
|
10380
|
+
}]
|
|
10381
|
+
]);
|
|
10382
|
+
/**
|
|
10383
|
+
* @license lucide-react v0.576.0 - ISC
|
|
10384
|
+
*
|
|
10385
|
+
* This source code is licensed under the ISC license.
|
|
10386
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
10387
|
+
*/
|
|
10365
10388
|
var ListChecks = createLucideIcon("list-checks", [
|
|
10366
10389
|
["path", {
|
|
10367
10390
|
d: "M13 5h8",
|
|
@@ -13074,13 +13097,33 @@ function DialogTrigger({ children, ...props }) {
|
|
|
13074
13097
|
* whenever they are asymmetric (they are: ~59px top vs ~34px bottom). Every
|
|
13075
13098
|
* term collapses to the previous `100dvh - 2rem` where the insets are 0.
|
|
13076
13099
|
*/
|
|
13077
|
-
var contentVariants = cva("fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 m-0 w-[calc(100vw-2rem)] bg-background-elevated text-foreground border border-border rounded-lg p-4 backdrop:bg-black/50 backdrop:backdrop-blur-sm max-h-[calc(100dvh-2rem-2*max(env(safe-area-inset-top),env(safe-area-inset-bottom)))]
|
|
13100
|
+
var contentVariants = cva("fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 m-0 w-[calc(100vw-2rem)] bg-background-elevated text-foreground border border-border rounded-lg p-4 backdrop:bg-black/50 backdrop:backdrop-blur-sm max-h-[calc(100dvh-2rem-2*max(env(safe-area-inset-top),env(safe-area-inset-bottom)))] [&:not([open])]:hidden", {
|
|
13078
13101
|
variants: {
|
|
13079
13102
|
width: {
|
|
13080
13103
|
sm: "max-w-sm",
|
|
13081
13104
|
md: "max-w-md",
|
|
13082
13105
|
lg: "max-w-lg"
|
|
13083
13106
|
},
|
|
13107
|
+
/**
|
|
13108
|
+
* WHICH box scrolls when the content is taller than the height cap.
|
|
13109
|
+
*
|
|
13110
|
+
* `content` (default) scrolls the whole dialog: the title, any tab strip
|
|
13111
|
+
* and the Save/Cancel row travel with the fields. That is fine for a
|
|
13112
|
+
* short form and wrong for a long one — the operator scrolls to the
|
|
13113
|
+
* bottom of a rule editor and the buttons are somewhere above him, the
|
|
13114
|
+
* tab strip is gone, and the only way back to either is to scroll the
|
|
13115
|
+
* form he was reading.
|
|
13116
|
+
*
|
|
13117
|
+
* `body` makes the dialog a flex column that does not scroll at all and
|
|
13118
|
+
* hands the scrolling to `<DialogBody>`. The chrome around the body —
|
|
13119
|
+
* header, tab strip, footer — stays put at every height. The dialog
|
|
13120
|
+
* keeps its own padding either way, so `sheet-on-mobile`'s
|
|
13121
|
+
* safe-area bottom padding still applies to the footer.
|
|
13122
|
+
*/
|
|
13123
|
+
scroll: {
|
|
13124
|
+
content: "overflow-y-auto",
|
|
13125
|
+
body: "flex flex-col overflow-hidden"
|
|
13126
|
+
},
|
|
13084
13127
|
placement: {
|
|
13085
13128
|
center: "",
|
|
13086
13129
|
"sheet-on-mobile": "bottom-0 left-0 right-0 top-auto w-full max-w-full translate-x-0 translate-y-0 rounded-b-none max-h-[92dvh] pb-[calc(1rem+env(safe-area-inset-bottom))] md:left-1/2 md:top-1/2 md:-translate-x-1/2 md:-translate-y-1/2 md:rounded-lg md:bottom-auto md:pb-4 md:max-h-[calc(100dvh-2rem-2*max(env(safe-area-inset-top),env(safe-area-inset-bottom)))]"
|
|
@@ -13105,10 +13148,11 @@ var contentVariants = cva("fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-
|
|
|
13105
13148
|
],
|
|
13106
13149
|
defaultVariants: {
|
|
13107
13150
|
width: "md",
|
|
13108
|
-
placement: "center"
|
|
13151
|
+
placement: "center",
|
|
13152
|
+
scroll: "content"
|
|
13109
13153
|
}
|
|
13110
13154
|
});
|
|
13111
|
-
var DialogContent = (0, react$1.forwardRef)(({ className, width, placement, children, ...props }, ref) => {
|
|
13155
|
+
var DialogContent = (0, react$1.forwardRef)(({ className, width, placement, scroll, children, ...props }, ref) => {
|
|
13112
13156
|
const { open, setOpen, contentId } = useDialogContext();
|
|
13113
13157
|
const innerRef = (0, react$1.useRef)(null);
|
|
13114
13158
|
const dialogRef = ref ?? innerRef;
|
|
@@ -13126,7 +13170,8 @@ var DialogContent = (0, react$1.forwardRef)(({ className, width, placement, chil
|
|
|
13126
13170
|
id: contentId,
|
|
13127
13171
|
className: cn(contentVariants({
|
|
13128
13172
|
width,
|
|
13129
|
-
placement
|
|
13173
|
+
placement,
|
|
13174
|
+
scroll
|
|
13130
13175
|
}), className),
|
|
13131
13176
|
onClick: handleClick,
|
|
13132
13177
|
onClose: () => setOpen(false),
|
|
@@ -13135,15 +13180,33 @@ var DialogContent = (0, react$1.forwardRef)(({ className, width, placement, chil
|
|
|
13135
13180
|
});
|
|
13136
13181
|
});
|
|
13137
13182
|
DialogContent.displayName = "DialogContent";
|
|
13183
|
+
/** `shrink-0` is inert outside a flex column and load-bearing inside one:
|
|
13184
|
+
* under `scroll="body"` the header must keep its height while the body gives
|
|
13185
|
+
* ground, or a long form squeezes its own title to nothing. */
|
|
13138
13186
|
function DialogHeader({ className, ...props }) {
|
|
13139
13187
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
13140
|
-
className: cn("flex flex-col gap-1 mb-3", className),
|
|
13188
|
+
className: cn("flex flex-col gap-1 mb-3 shrink-0", className),
|
|
13189
|
+
...props
|
|
13190
|
+
});
|
|
13191
|
+
}
|
|
13192
|
+
/**
|
|
13193
|
+
* The scrolling middle of a `scroll="body"` dialog.
|
|
13194
|
+
*
|
|
13195
|
+
* `min-h-0` is what makes it work: a flex item's default `min-height: auto`
|
|
13196
|
+
* refuses to shrink below its content, so without it the body pushes the
|
|
13197
|
+
* footer out of the dialog instead of scrolling — the exact failure this
|
|
13198
|
+
* variant exists to remove. Anything sticky INSIDE a dialog body pins against
|
|
13199
|
+
* this element, since it is the scroll container.
|
|
13200
|
+
*/
|
|
13201
|
+
function DialogBody({ className, ...props }) {
|
|
13202
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
13203
|
+
className: cn("flex-1 min-h-0 overflow-y-auto", className),
|
|
13141
13204
|
...props
|
|
13142
13205
|
});
|
|
13143
13206
|
}
|
|
13144
13207
|
function DialogFooter({ className, ...props }) {
|
|
13145
13208
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
13146
|
-
className: cn("flex justify-end gap-2 mt-4", className),
|
|
13209
|
+
className: cn("flex justify-end gap-2 mt-4 shrink-0", className),
|
|
13147
13210
|
...props
|
|
13148
13211
|
});
|
|
13149
13212
|
}
|
|
@@ -13544,10 +13607,27 @@ function Tabs({ value: controlledValue, onValueChange, defaultValue = "", classN
|
|
|
13544
13607
|
})
|
|
13545
13608
|
});
|
|
13546
13609
|
}
|
|
13610
|
+
/**
|
|
13611
|
+
* The strip SCROLLS sideways; it never squashes and never clips.
|
|
13612
|
+
*
|
|
13613
|
+
* Before this, the bar was a bare `flex flex-row`: every trigger was a
|
|
13614
|
+
* shrinkable flex item with no `whitespace-nowrap`, so past ~4 tabs on a narrow
|
|
13615
|
+
* viewport the labels wrapped inside a fixed-height (`h-7`) button and the
|
|
13616
|
+
* last tabs left the box with no way to reach them. That is the "the headers
|
|
13617
|
+
* are not scrollable" report on the Notification Center, whose rule sections
|
|
13618
|
+
* (6) and rule-editor tabs (up to 6) both live in one of these.
|
|
13619
|
+
*
|
|
13620
|
+
* BOTH axes are spelled out on purpose. Per CSS Overflow, `visible` is not
|
|
13621
|
+
* combinable with `auto`, so `overflow-x-auto` alone would silently promote
|
|
13622
|
+
* `overflow-y` to `auto` as well — and a one-row bar whose content box is
|
|
13623
|
+
* shortened by the horizontal scrollbar then raises a SECOND, vertical
|
|
13624
|
+
* scrollbar. That exact defect is documented on `SchemaTabBar`; `overflow-y`
|
|
13625
|
+
* pinned to `hidden` makes it impossible here.
|
|
13626
|
+
*/
|
|
13547
13627
|
function TabsList({ className, ...props }) {
|
|
13548
13628
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
13549
13629
|
role: "tablist",
|
|
13550
|
-
className: cn("flex flex-row border-b border-border-subtle", className),
|
|
13630
|
+
className: cn("flex flex-row overflow-x-auto overflow-y-hidden border-b border-border-subtle", className),
|
|
13551
13631
|
...props
|
|
13552
13632
|
});
|
|
13553
13633
|
}
|
|
@@ -13560,7 +13640,7 @@ function TabsTrigger({ value, className, ...props }) {
|
|
|
13560
13640
|
"aria-selected": isActive,
|
|
13561
13641
|
"aria-controls": `tabpanel-${value}`,
|
|
13562
13642
|
tabIndex: isActive ? 0 : -1,
|
|
13563
|
-
className: cn("h-7 text-xs px-3 transition-colors", isActive ? "border-b-2 border-primary text-foreground font-medium" : "text-foreground-muted hover:text-foreground", className),
|
|
13643
|
+
className: cn("h-7 shrink-0 whitespace-nowrap text-xs px-3 transition-colors", isActive ? "border-b-2 border-primary text-foreground font-medium" : "text-foreground-muted hover:text-foreground", className),
|
|
13564
13644
|
onClick: () => setValue(value),
|
|
13565
13645
|
...props
|
|
13566
13646
|
});
|
|
@@ -13829,25 +13909,34 @@ function SettingRow({ label, children, actions, elements = "plain", className, v
|
|
|
13829
13909
|
}
|
|
13830
13910
|
//#endregion
|
|
13831
13911
|
//#region src/composites/data-table.tsx
|
|
13912
|
+
/**
|
|
13913
|
+
* The default cap for an internally-scrolling table.
|
|
13914
|
+
*
|
|
13915
|
+
* `60vh` leaves the page header, its filters and its actions on screen at
|
|
13916
|
+
* every viewport while still showing ~10 rows — and it is a fraction, so it
|
|
13917
|
+
* does not need a breakpoint of its own.
|
|
13918
|
+
*/
|
|
13919
|
+
var TABLE_SCROLL_MAX_H = "max-h-[60vh]";
|
|
13832
13920
|
var ALIGN_CLASS = {
|
|
13833
13921
|
left: "text-left",
|
|
13834
13922
|
right: "text-right",
|
|
13835
13923
|
center: "text-center"
|
|
13836
13924
|
};
|
|
13837
|
-
function DataTable({ columns, rows, rowKey, onRowClick, minWidthPx = 480, emptyMessage, className, bordered = true, rowClassName, mobileMode = "auto" }) {
|
|
13925
|
+
function DataTable({ columns, rows, rowKey, onRowClick, minWidthPx = 480, emptyMessage, className, bordered = true, rowClassName, mobileMode = "auto", maxBodyHeight }) {
|
|
13838
13926
|
const isNarrow = useIsMobile();
|
|
13839
13927
|
const layout = resolveTableLayout({
|
|
13840
13928
|
mode: mobileMode,
|
|
13841
13929
|
columnCount: columns.length,
|
|
13842
13930
|
isNarrow
|
|
13843
13931
|
});
|
|
13932
|
+
const scrolls = maxBodyHeight !== void 0;
|
|
13844
13933
|
if (rows.length === 0 && emptyMessage) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
13845
13934
|
className: `rounded-lg ${bordered ? "border border-border" : ""} bg-surface px-3 py-4 text-xs text-foreground-subtle text-center ${className ?? ""}`,
|
|
13846
13935
|
children: emptyMessage
|
|
13847
13936
|
});
|
|
13848
13937
|
if (rows.length === 0) return null;
|
|
13849
13938
|
if (layout === "cards") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
13850
|
-
className: `space-y-2 ${className ?? ""}`,
|
|
13939
|
+
className: `space-y-2 ${scrolls ? `${maxBodyHeight} overflow-y-auto` : ""} ${className ?? ""}`,
|
|
13851
13940
|
children: rows.map((row, rowIndex) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DataTableCard, {
|
|
13852
13941
|
row,
|
|
13853
13942
|
rowIndex,
|
|
@@ -13858,9 +13947,15 @@ function DataTable({ columns, rows, rowKey, onRowClick, minWidthPx = 480, emptyM
|
|
|
13858
13947
|
}, rowKey ? rowKey(row, rowIndex) : rowIndex))
|
|
13859
13948
|
});
|
|
13860
13949
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
13861
|
-
className:
|
|
13950
|
+
className: [
|
|
13951
|
+
"rounded-lg",
|
|
13952
|
+
bordered ? "border border-border" : "",
|
|
13953
|
+
"bg-surface overflow-x-auto",
|
|
13954
|
+
scrolls ? `${maxBodyHeight} overflow-y-auto` : "",
|
|
13955
|
+
className ?? ""
|
|
13956
|
+
].filter(Boolean).join(" "),
|
|
13862
13957
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
|
|
13863
|
-
className:
|
|
13958
|
+
className: `w-full text-xs ${scrolls ? "border-separate border-spacing-0" : ""}`,
|
|
13864
13959
|
style: { minWidth: `${minWidthPx}px` },
|
|
13865
13960
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", {
|
|
13866
13961
|
className: "bg-surface",
|
|
@@ -13870,6 +13965,7 @@ function DataTable({ columns, rows, rowKey, onRowClick, minWidthPx = 480, emptyM
|
|
|
13870
13965
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", {
|
|
13871
13966
|
className: [
|
|
13872
13967
|
"px-3 py-2 text-foreground-subtle font-medium bg-surface border-b border-border",
|
|
13968
|
+
scrolls ? "sticky top-0 z-10" : "",
|
|
13873
13969
|
ALIGN_CLASS[align],
|
|
13874
13970
|
nowrap ? "whitespace-nowrap" : "",
|
|
13875
13971
|
col.width ?? ""
|
|
@@ -46880,6 +46976,345 @@ function KebabMenu({ items, header, triggerClassName, title = "More actions" })
|
|
|
46880
46976
|
});
|
|
46881
46977
|
}
|
|
46882
46978
|
//#endregion
|
|
46979
|
+
//#region src/composites/device-selector-picker.tsx
|
|
46980
|
+
/**
|
|
46981
|
+
* <DeviceSelectorPicker> — the operator-facing editor for a v3
|
|
46982
|
+
* {@link DeviceSelector}: the "on WHICH devices" half of a scope grant.
|
|
46983
|
+
*
|
|
46984
|
+
* Four tabs, one per selector kind, because the kinds are not interchangeable
|
|
46985
|
+
* and the operator has to choose between them deliberately:
|
|
46986
|
+
*
|
|
46987
|
+
* All → every device, now and forever. DYNAMIC.
|
|
46988
|
+
* By type → every device of a `DeviceType`. DYNAMIC — a camera added
|
|
46989
|
+
* tomorrow is covered with no re-grant.
|
|
46990
|
+
* By room → every device carrying an operator-assigned `location` label.
|
|
46991
|
+
* DYNAMIC. Rendered ONLY when the fleet actually has labels;
|
|
46992
|
+
* an empty tab reads as a broken feature, not as "unused".
|
|
46993
|
+
* Chosen → an explicit id list. STATIC — this is the one that does NOT
|
|
46994
|
+
* cover a new device, and the tab says so.
|
|
46995
|
+
*
|
|
46996
|
+
* Counts are LIVE (computed off `deviceManager.listAll`) so "Camera · 12"
|
|
46997
|
+
* answers "how much am I granting" without leaving the panel.
|
|
46998
|
+
*
|
|
46999
|
+
* Shared, not forked: the raw `<ScopePicker>` device row and the simple
|
|
47000
|
+
* Access panel both render this. Two editors for one wire shape is how a
|
|
47001
|
+
* selector ends up meaning different things in two places.
|
|
47002
|
+
*/
|
|
47003
|
+
/** Human label for a `DeviceType` — 'lawn-mower' → 'Lawn mower'. */
|
|
47004
|
+
function deviceTypeLabel(type) {
|
|
47005
|
+
const spaced = type.replaceAll("-", " ");
|
|
47006
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
47007
|
+
}
|
|
47008
|
+
/** Which tab a selector belongs to. */
|
|
47009
|
+
function tabForSelector(selector) {
|
|
47010
|
+
return selector.kind;
|
|
47011
|
+
}
|
|
47012
|
+
/**
|
|
47013
|
+
* A fresh selector when the operator switches tabs. Switching is destructive
|
|
47014
|
+
* by nature — an `ids` list has no meaning as a `types` list — so each tab
|
|
47015
|
+
* starts empty rather than guessing a translation.
|
|
47016
|
+
*/
|
|
47017
|
+
function emptySelectorFor(tab) {
|
|
47018
|
+
switch (tab) {
|
|
47019
|
+
case "all": return { kind: "all" };
|
|
47020
|
+
case "types": return {
|
|
47021
|
+
kind: "types",
|
|
47022
|
+
types: []
|
|
47023
|
+
};
|
|
47024
|
+
case "locations": return {
|
|
47025
|
+
kind: "locations",
|
|
47026
|
+
locations: []
|
|
47027
|
+
};
|
|
47028
|
+
case "ids": return {
|
|
47029
|
+
kind: "ids",
|
|
47030
|
+
ids: []
|
|
47031
|
+
};
|
|
47032
|
+
}
|
|
47033
|
+
}
|
|
47034
|
+
function DeviceSelectorPicker({ value, onChange, includeLinked, onIncludeLinkedChange, clampToDeviceIds, dense = false }) {
|
|
47035
|
+
const { data: rawDevices } = useDeviceManagerListAll({});
|
|
47036
|
+
const [search, setSearch] = (0, react$1.useState)("");
|
|
47037
|
+
const devices = (0, react$1.useMemo)(() => {
|
|
47038
|
+
const all = (rawDevices ?? []).map((d) => ({
|
|
47039
|
+
id: d.id,
|
|
47040
|
+
name: d.name,
|
|
47041
|
+
type: d.type,
|
|
47042
|
+
location: d.location,
|
|
47043
|
+
parentDeviceId: d.parentDeviceId
|
|
47044
|
+
}));
|
|
47045
|
+
if (clampToDeviceIds == null) return all;
|
|
47046
|
+
const allowed = new Set(clampToDeviceIds);
|
|
47047
|
+
return all.filter((d) => allowed.has(d.id));
|
|
47048
|
+
}, [rawDevices, clampToDeviceIds]);
|
|
47049
|
+
const typeOptions = (0, react$1.useMemo)(() => {
|
|
47050
|
+
const counts = /* @__PURE__ */ new Map();
|
|
47051
|
+
for (const d of devices) counts.set(d.type, (counts.get(d.type) ?? 0) + 1);
|
|
47052
|
+
if (value.kind === "types") {
|
|
47053
|
+
for (const t of value.types) if (!counts.has(t)) counts.set(t, 0);
|
|
47054
|
+
}
|
|
47055
|
+
return [...counts.entries()].map(([type, count]) => ({
|
|
47056
|
+
value: type,
|
|
47057
|
+
label: deviceTypeLabel(type),
|
|
47058
|
+
count
|
|
47059
|
+
})).sort((a, b) => a.label.localeCompare(b.label));
|
|
47060
|
+
}, [devices, value]);
|
|
47061
|
+
const locationOptions = (0, react$1.useMemo)(() => {
|
|
47062
|
+
const counts = /* @__PURE__ */ new Map();
|
|
47063
|
+
for (const d of devices) {
|
|
47064
|
+
if (d.location === null || d.location.length === 0) continue;
|
|
47065
|
+
counts.set(d.location, (counts.get(d.location) ?? 0) + 1);
|
|
47066
|
+
}
|
|
47067
|
+
if (value.kind === "locations") {
|
|
47068
|
+
for (const l of value.locations) if (!counts.has(l)) counts.set(l, 0);
|
|
47069
|
+
}
|
|
47070
|
+
return [...counts.entries()].map(([location, count]) => ({
|
|
47071
|
+
value: location,
|
|
47072
|
+
label: location,
|
|
47073
|
+
count
|
|
47074
|
+
})).sort((a, b) => a.label.localeCompare(b.label));
|
|
47075
|
+
}, [devices, value]);
|
|
47076
|
+
/** The Room tab exists only when the fleet has rooms. */
|
|
47077
|
+
const hasLocations = locationOptions.length > 0;
|
|
47078
|
+
const visibleDevices = (0, react$1.useMemo)(() => {
|
|
47079
|
+
const q = search.trim().toLowerCase();
|
|
47080
|
+
return [...q.length === 0 ? devices : devices.filter((d) => d.name.toLowerCase().includes(q) || String(d.id).includes(q) || (d.location ?? "").toLowerCase().includes(q))].sort((a, b) => a.name.localeCompare(b.name));
|
|
47081
|
+
}, [devices, search]);
|
|
47082
|
+
const activeTab = tabForSelector(value);
|
|
47083
|
+
const tabs = [
|
|
47084
|
+
{
|
|
47085
|
+
id: "all",
|
|
47086
|
+
label: "All"
|
|
47087
|
+
},
|
|
47088
|
+
{
|
|
47089
|
+
id: "types",
|
|
47090
|
+
label: "By type"
|
|
47091
|
+
},
|
|
47092
|
+
...hasLocations || activeTab === "locations" ? [{
|
|
47093
|
+
id: "locations",
|
|
47094
|
+
label: "By room"
|
|
47095
|
+
}] : [],
|
|
47096
|
+
{
|
|
47097
|
+
id: "ids",
|
|
47098
|
+
label: "Chosen"
|
|
47099
|
+
}
|
|
47100
|
+
];
|
|
47101
|
+
const toggleType = (type) => {
|
|
47102
|
+
const current = value.kind === "types" ? value.types : [];
|
|
47103
|
+
onChange({
|
|
47104
|
+
kind: "types",
|
|
47105
|
+
types: current.some((t) => String(t) === type) ? current.filter((t) => String(t) !== type) : [...current, ...typeFromString(type)]
|
|
47106
|
+
});
|
|
47107
|
+
};
|
|
47108
|
+
const toggleLocation = (location) => {
|
|
47109
|
+
const current = value.kind === "locations" ? value.locations : [];
|
|
47110
|
+
onChange({
|
|
47111
|
+
kind: "locations",
|
|
47112
|
+
locations: current.includes(location) ? current.filter((l) => l !== location) : [...current, location]
|
|
47113
|
+
});
|
|
47114
|
+
};
|
|
47115
|
+
const toggleId = (id) => {
|
|
47116
|
+
const current = value.kind === "ids" ? value.ids : [];
|
|
47117
|
+
onChange({
|
|
47118
|
+
kind: "ids",
|
|
47119
|
+
ids: current.includes(id) ? current.filter((v) => v !== id) : [...current, id]
|
|
47120
|
+
});
|
|
47121
|
+
};
|
|
47122
|
+
const pad = dense ? "p-2" : "p-3";
|
|
47123
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
47124
|
+
className: "space-y-2",
|
|
47125
|
+
children: [
|
|
47126
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
47127
|
+
className: "flex flex-wrap gap-1 border-b border-border pb-1",
|
|
47128
|
+
children: tabs.map((t) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
47129
|
+
type: "button",
|
|
47130
|
+
onClick: () => onChange(emptySelectorFor(t.id)),
|
|
47131
|
+
className: `shrink-0 whitespace-nowrap rounded px-2 py-1 text-[11px] transition-colors ${activeTab === t.id ? "bg-primary/15 text-primary font-medium" : "text-foreground-subtle hover:bg-foreground-subtle/10 hover:text-foreground"}`,
|
|
47132
|
+
children: t.label
|
|
47133
|
+
}, t.id))
|
|
47134
|
+
}),
|
|
47135
|
+
activeTab === "all" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
47136
|
+
className: `rounded border border-border bg-surface ${pad}`,
|
|
47137
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
47138
|
+
className: "text-[11px] text-foreground",
|
|
47139
|
+
children: [
|
|
47140
|
+
"Every device in this deployment — ",
|
|
47141
|
+
devices.length,
|
|
47142
|
+
" right now."
|
|
47143
|
+
]
|
|
47144
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
47145
|
+
className: "mt-1 text-[10px] text-foreground-subtle",
|
|
47146
|
+
children: "Devices added later are covered automatically."
|
|
47147
|
+
})]
|
|
47148
|
+
}),
|
|
47149
|
+
activeTab === "types" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CheckboxGrid, {
|
|
47150
|
+
options: typeOptions,
|
|
47151
|
+
selected: value.kind === "types" ? value.types.map((t) => String(t)) : [],
|
|
47152
|
+
onToggle: toggleType,
|
|
47153
|
+
emptyLabel: "No devices to pick a type from.",
|
|
47154
|
+
footnote: "Devices of a checked type are covered automatically as they are added.",
|
|
47155
|
+
pad
|
|
47156
|
+
}),
|
|
47157
|
+
activeTab === "locations" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CheckboxGrid, {
|
|
47158
|
+
options: locationOptions,
|
|
47159
|
+
selected: value.kind === "locations" ? [...value.locations] : [],
|
|
47160
|
+
onToggle: toggleLocation,
|
|
47161
|
+
emptyLabel: "No device has a room assigned yet.",
|
|
47162
|
+
footnote: "A device with no room set is never covered by a room rule.",
|
|
47163
|
+
pad
|
|
47164
|
+
}),
|
|
47165
|
+
activeTab === "ids" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
47166
|
+
className: `rounded border border-border bg-surface ${pad} space-y-2`,
|
|
47167
|
+
children: [
|
|
47168
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
47169
|
+
className: "relative",
|
|
47170
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Search, { className: "pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-foreground-subtle" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
47171
|
+
type: "search",
|
|
47172
|
+
value: search,
|
|
47173
|
+
onChange: (e) => setSearch(e.target.value),
|
|
47174
|
+
placeholder: "Search devices…",
|
|
47175
|
+
className: "w-full rounded border border-border bg-background py-1 pl-7 pr-2 text-[11px] text-foreground placeholder:text-foreground-subtle focus:outline-none focus:ring-1 focus:ring-primary"
|
|
47176
|
+
})]
|
|
47177
|
+
}),
|
|
47178
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
47179
|
+
className: "max-h-56 overflow-auto rounded border border-border",
|
|
47180
|
+
children: [visibleDevices.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
47181
|
+
className: "px-2 py-3 text-[10px] italic text-foreground-subtle",
|
|
47182
|
+
children: "No device matches."
|
|
47183
|
+
}), visibleDevices.map((d) => {
|
|
47184
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
47185
|
+
className: "flex cursor-pointer items-center gap-2 px-2 py-1.5 text-[11px] hover:bg-foreground-subtle/10",
|
|
47186
|
+
children: [
|
|
47187
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
47188
|
+
type: "checkbox",
|
|
47189
|
+
checked: value.kind === "ids" && value.ids.includes(d.id),
|
|
47190
|
+
onChange: () => toggleId(d.id),
|
|
47191
|
+
className: "rounded border-border focus:ring-primary"
|
|
47192
|
+
}),
|
|
47193
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
47194
|
+
className: "min-w-0 flex-1 truncate text-foreground",
|
|
47195
|
+
children: d.name
|
|
47196
|
+
}),
|
|
47197
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
47198
|
+
className: "shrink-0 font-mono text-[10px] text-foreground-subtle",
|
|
47199
|
+
children: [
|
|
47200
|
+
"#",
|
|
47201
|
+
d.id,
|
|
47202
|
+
d.location !== null && d.location.length > 0 ? ` · ${d.location}` : ""
|
|
47203
|
+
]
|
|
47204
|
+
})
|
|
47205
|
+
]
|
|
47206
|
+
}, d.id);
|
|
47207
|
+
})]
|
|
47208
|
+
}),
|
|
47209
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
47210
|
+
className: "text-[10px] text-foreground-subtle",
|
|
47211
|
+
children: [
|
|
47212
|
+
"A fixed list — a device added later is ",
|
|
47213
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
47214
|
+
className: "font-medium",
|
|
47215
|
+
children: "not"
|
|
47216
|
+
}),
|
|
47217
|
+
" covered until you edit this."
|
|
47218
|
+
]
|
|
47219
|
+
})
|
|
47220
|
+
]
|
|
47221
|
+
}),
|
|
47222
|
+
includeLinked !== void 0 && onIncludeLinkedChange !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IncludeLinkedToggle, {
|
|
47223
|
+
value: includeLinked,
|
|
47224
|
+
onChange: onIncludeLinkedChange,
|
|
47225
|
+
pad
|
|
47226
|
+
})
|
|
47227
|
+
]
|
|
47228
|
+
});
|
|
47229
|
+
}
|
|
47230
|
+
/**
|
|
47231
|
+
* Turn a raw string back into the `DeviceType` union without a cast. The
|
|
47232
|
+
* enum's values ARE the strings, so a lookup over its members is both exact
|
|
47233
|
+
* and total — an unknown string yields no entry and selects nothing.
|
|
47234
|
+
*/
|
|
47235
|
+
function typeFromString(raw) {
|
|
47236
|
+
return Object.values(_camstack_types.DeviceType).filter((t) => String(t) === raw);
|
|
47237
|
+
}
|
|
47238
|
+
function CheckboxGrid({ options, selected, onToggle, emptyLabel, footnote, pad }) {
|
|
47239
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
47240
|
+
className: `rounded border border-border bg-surface ${pad} space-y-2`,
|
|
47241
|
+
children: [options.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
47242
|
+
className: "text-[10px] italic text-foreground-subtle",
|
|
47243
|
+
children: emptyLabel
|
|
47244
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
47245
|
+
className: "grid max-h-56 grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-1 overflow-auto",
|
|
47246
|
+
children: options.map((o) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
47247
|
+
className: "flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 text-[11px] hover:bg-foreground-subtle/10",
|
|
47248
|
+
children: [
|
|
47249
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
47250
|
+
type: "checkbox",
|
|
47251
|
+
checked: selected.includes(o.value),
|
|
47252
|
+
onChange: () => onToggle(o.value),
|
|
47253
|
+
className: "rounded border-border focus:ring-primary"
|
|
47254
|
+
}),
|
|
47255
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
47256
|
+
className: "min-w-0 flex-1 truncate text-foreground",
|
|
47257
|
+
children: o.label
|
|
47258
|
+
}),
|
|
47259
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
47260
|
+
className: "shrink-0 font-mono text-[10px] text-foreground-subtle",
|
|
47261
|
+
children: o.count
|
|
47262
|
+
})
|
|
47263
|
+
]
|
|
47264
|
+
}, o.value))
|
|
47265
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
47266
|
+
className: "text-[10px] text-foreground-subtle",
|
|
47267
|
+
children: footnote
|
|
47268
|
+
})]
|
|
47269
|
+
});
|
|
47270
|
+
}
|
|
47271
|
+
/**
|
|
47272
|
+
* The toggle whose OFF state is a real semantic restriction, so the hint says
|
|
47273
|
+
* what it costs rather than describing the mechanism: without it, an operator
|
|
47274
|
+
* granted a camera cannot fire the siren or floodlight attached to it,
|
|
47275
|
+
* because `create` does not inherit parent → child by default.
|
|
47276
|
+
*/
|
|
47277
|
+
function IncludeLinkedToggle({ value, onChange, pad }) {
|
|
47278
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
47279
|
+
className: `rounded border border-border bg-surface ${pad}`,
|
|
47280
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
47281
|
+
className: "flex cursor-pointer items-start gap-2",
|
|
47282
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
47283
|
+
type: "checkbox",
|
|
47284
|
+
checked: value,
|
|
47285
|
+
onChange: (e) => onChange(e.target.checked),
|
|
47286
|
+
className: "mt-0.5 rounded border-border focus:ring-primary"
|
|
47287
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
47288
|
+
className: "min-w-0",
|
|
47289
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
47290
|
+
className: "flex items-center gap-1.5 text-[11px] font-medium text-foreground",
|
|
47291
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Link2, { className: "h-3 w-3" }), "Include linked devices"]
|
|
47292
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
47293
|
+
className: "mt-0.5 block text-[10px] text-foreground-subtle",
|
|
47294
|
+
children: value ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: "Accessories attached to a granted camera — siren, floodlight, PIR — are covered too." }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
47295
|
+
"Only the devices selected above. An operator granted a camera will",
|
|
47296
|
+
" ",
|
|
47297
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
47298
|
+
className: "font-medium text-foreground",
|
|
47299
|
+
children: "not"
|
|
47300
|
+
}),
|
|
47301
|
+
" be able to trigger the siren or light attached to it."
|
|
47302
|
+
] })
|
|
47303
|
+
})]
|
|
47304
|
+
})]
|
|
47305
|
+
})
|
|
47306
|
+
});
|
|
47307
|
+
}
|
|
47308
|
+
/** Summary line for a selector — used by the raw picker's collapsed row. */
|
|
47309
|
+
function describeDeviceSelector(selector) {
|
|
47310
|
+
switch (selector.kind) {
|
|
47311
|
+
case "all": return "All devices";
|
|
47312
|
+
case "ids": return selector.ids.length === 0 ? "No device chosen" : `${selector.ids.length} device${selector.ids.length === 1 ? "" : "s"}`;
|
|
47313
|
+
case "types": return selector.types.length === 0 ? "No type chosen" : selector.types.map((t) => deviceTypeLabel(String(t))).join(", ");
|
|
47314
|
+
case "locations": return selector.locations.length === 0 ? "No room chosen" : selector.locations.join(", ");
|
|
47315
|
+
}
|
|
47316
|
+
}
|
|
47317
|
+
//#endregion
|
|
46883
47318
|
//#region src/composites/scope-picker.tsx
|
|
46884
47319
|
/**
|
|
46885
47320
|
* <ScopePicker> — operator-facing editor for a `TokenScope[]` value.
|
|
@@ -46893,13 +47328,24 @@ function KebabMenu({ items, header, triggerClassName, title = "More actions" })
|
|
|
46893
47328
|
* - category → target: 'device' | 'system'
|
|
46894
47329
|
* - capability → target: cap name from `KNOWN_CAP_NAMES`
|
|
46895
47330
|
* - addon → target: addon id from `useAddonsList`
|
|
46896
|
-
* - device →
|
|
47331
|
+
* - device → a v3 `DeviceSelector` (all / ids / types / locations)
|
|
47332
|
+
* plus `includeLinked`, edited by `<DeviceSelectorPicker>`
|
|
46897
47333
|
*
|
|
46898
47334
|
* `access` is always 3 independent checkboxes (view / create / delete).
|
|
46899
47335
|
*
|
|
46900
47336
|
* `clampToParent` narrows the target dropdown + access checkboxes to
|
|
46901
47337
|
* what the CALLER possesses. Admins pass `null` to allow everything.
|
|
47338
|
+
*
|
|
47339
|
+
* This is the RAW editor. The simple, two-question Access panel on
|
|
47340
|
+
* `/system/users` composes the same wire shape through `ACCESS_ROLES`; this
|
|
47341
|
+
* one is what an operator drops into when a grant set is not a plain role.
|
|
46902
47342
|
*/
|
|
47343
|
+
/** `<select>` value → the tri-state `includeLinked` it means. */
|
|
47344
|
+
var INCLUDE_LINKED_VALUES = {
|
|
47345
|
+
"": void 0,
|
|
47346
|
+
yes: true,
|
|
47347
|
+
no: false
|
|
47348
|
+
};
|
|
46903
47349
|
var ACCESS_OPTIONS = [
|
|
46904
47350
|
{
|
|
46905
47351
|
value: "view",
|
|
@@ -46930,14 +47376,24 @@ function defaultRow() {
|
|
|
46930
47376
|
};
|
|
46931
47377
|
}
|
|
46932
47378
|
/**
|
|
46933
|
-
*
|
|
46934
|
-
* the device picker so a child grant can't refer to a
|
|
46935
|
-
* caller themselves doesn't have.
|
|
47379
|
+
* The deviceIds a parent's grants actually resolve to, against the live
|
|
47380
|
+
* fleet. Used to clamp the device picker so a child grant can't refer to a
|
|
47381
|
+
* device the caller themselves doesn't have.
|
|
47382
|
+
*
|
|
47383
|
+
* v3 selectors are not a literal id list, so this RESOLVES them through the
|
|
47384
|
+
* shared resolver rather than reading ids off the grant — a `types:['camera']`
|
|
47385
|
+
* parent must clamp to its cameras, and re-deriving that here is exactly the
|
|
47386
|
+
* drift D103 warns about. A parent holding `category:device` (unrestricted)
|
|
47387
|
+
* yields the whole fleet.
|
|
46936
47388
|
*/
|
|
46937
|
-
function
|
|
47389
|
+
function parentDeviceIds(parent, fleet) {
|
|
46938
47390
|
if (parent == null) return [];
|
|
46939
47391
|
const out = /* @__PURE__ */ new Set();
|
|
46940
|
-
for (const
|
|
47392
|
+
for (const access of [
|
|
47393
|
+
"view",
|
|
47394
|
+
"create",
|
|
47395
|
+
"delete"
|
|
47396
|
+
]) for (const id of (0, _camstack_types.resolveViewableDeviceIds)(parent, fleet, access)) out.add(id);
|
|
46941
47397
|
return [...out];
|
|
46942
47398
|
}
|
|
46943
47399
|
function clampedSingleTargets(type, parent, addonChoices, capChoices) {
|
|
@@ -46968,7 +47424,7 @@ function clampedAccessForRow(row, parent) {
|
|
|
46968
47424
|
];
|
|
46969
47425
|
const matches = parent.filter((s) => {
|
|
46970
47426
|
if (s.type !== row.type) return false;
|
|
46971
|
-
if (s.type === "device" && row.type === "device") return
|
|
47427
|
+
if (s.type === "device" && row.type === "device") return true;
|
|
46972
47428
|
if (s.type !== "device" && row.type !== "device") return s.target === row.target;
|
|
46973
47429
|
return false;
|
|
46974
47430
|
});
|
|
@@ -46988,11 +47444,13 @@ function ScopePicker({ value, onChange, clampToParent, emptyHint }) {
|
|
|
46988
47444
|
value: c,
|
|
46989
47445
|
label: c
|
|
46990
47446
|
})), []);
|
|
46991
|
-
const
|
|
46992
|
-
|
|
46993
|
-
|
|
46994
|
-
|
|
46995
|
-
|
|
47447
|
+
const fleet = (0, react$1.useMemo)(() => (devices ?? []).map((d) => ({
|
|
47448
|
+
id: d.id,
|
|
47449
|
+
type: d.type,
|
|
47450
|
+
location: d.location,
|
|
47451
|
+
parentDeviceId: d.parentDeviceId
|
|
47452
|
+
})), [devices]);
|
|
47453
|
+
const parentDeviceClamp = (0, react$1.useMemo)(() => clampToParent == null ? null : parentDeviceIds(clampToParent, fleet), [clampToParent, fleet]);
|
|
46996
47454
|
/**
|
|
46997
47455
|
* Build a fresh scope row when `type` changes. The discriminated union
|
|
46998
47456
|
* forces us to re-construct the object per variant so the resulting
|
|
@@ -47018,7 +47476,10 @@ function ScopePicker({ value, onChange, clampToParent, emptyHint }) {
|
|
|
47018
47476
|
};
|
|
47019
47477
|
case "device": return {
|
|
47020
47478
|
type: "device",
|
|
47021
|
-
|
|
47479
|
+
selector: {
|
|
47480
|
+
kind: "ids",
|
|
47481
|
+
ids: []
|
|
47482
|
+
},
|
|
47022
47483
|
access: accessArr
|
|
47023
47484
|
};
|
|
47024
47485
|
}
|
|
@@ -47028,11 +47489,18 @@ function ScopePicker({ value, onChange, clampToParent, emptyHint }) {
|
|
|
47028
47489
|
if (i !== idx) return s;
|
|
47029
47490
|
if (patch.type !== void 0 && patch.type !== s.type) return rebuildForType(patch.type, patch.access ?? s.access);
|
|
47030
47491
|
const access = [...patch.access ?? s.access];
|
|
47031
|
-
if (s.type === "device")
|
|
47032
|
-
|
|
47033
|
-
|
|
47034
|
-
|
|
47035
|
-
|
|
47492
|
+
if (s.type === "device") {
|
|
47493
|
+
const base = {
|
|
47494
|
+
type: "device",
|
|
47495
|
+
selector: patch.selector ?? s.selector,
|
|
47496
|
+
access
|
|
47497
|
+
};
|
|
47498
|
+
const linked = "includeLinked" in patch ? patch.includeLinked : s.includeLinked;
|
|
47499
|
+
return linked === void 0 ? base : {
|
|
47500
|
+
...base,
|
|
47501
|
+
includeLinked: linked
|
|
47502
|
+
};
|
|
47503
|
+
}
|
|
47036
47504
|
if (s.type === "category") return {
|
|
47037
47505
|
type: "category",
|
|
47038
47506
|
target: patch.target ?? s.target,
|
|
@@ -47091,11 +47559,35 @@ function ScopePicker({ value, onChange, clampToParent, emptyHint }) {
|
|
|
47091
47559
|
})
|
|
47092
47560
|
]
|
|
47093
47561
|
}),
|
|
47094
|
-
row.type === "device" ? /* @__PURE__ */ (0, react_jsx_runtime.
|
|
47095
|
-
|
|
47096
|
-
|
|
47097
|
-
|
|
47098
|
-
|
|
47562
|
+
row.type === "device" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
47563
|
+
className: "min-w-[200px] flex-1 space-y-1.5",
|
|
47564
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(DeviceSelectorPicker, {
|
|
47565
|
+
dense: true,
|
|
47566
|
+
value: row.selector,
|
|
47567
|
+
onChange: (selector) => update(idx, { selector }),
|
|
47568
|
+
clampToDeviceIds: parentDeviceClamp
|
|
47569
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
47570
|
+
className: "flex flex-wrap items-center gap-1.5 text-[10px] text-foreground-subtle",
|
|
47571
|
+
children: ["Linked devices", /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
|
|
47572
|
+
value: row.includeLinked === void 0 ? "" : row.includeLinked ? "yes" : "no",
|
|
47573
|
+
onChange: (e) => update(idx, { includeLinked: INCLUDE_LINKED_VALUES[e.target.value] }),
|
|
47574
|
+
className: "rounded border border-border bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-primary",
|
|
47575
|
+
children: [
|
|
47576
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
|
|
47577
|
+
value: "",
|
|
47578
|
+
children: "Automatic (view inherits)"
|
|
47579
|
+
}),
|
|
47580
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
|
|
47581
|
+
value: "yes",
|
|
47582
|
+
children: "Always included"
|
|
47583
|
+
}),
|
|
47584
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
|
|
47585
|
+
value: "no",
|
|
47586
|
+
children: "Never included"
|
|
47587
|
+
})
|
|
47588
|
+
]
|
|
47589
|
+
})]
|
|
47590
|
+
})]
|
|
47099
47591
|
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
|
|
47100
47592
|
value: row.target,
|
|
47101
47593
|
onChange: (e) => update(idx, { target: e.target.value }),
|
|
@@ -47158,76 +47650,40 @@ function ScopePicker({ value, onChange, clampToParent, emptyHint }) {
|
|
|
47158
47650
|
]
|
|
47159
47651
|
});
|
|
47160
47652
|
}
|
|
47161
|
-
function DeviceTargetPicker({ value, choices, clampToParent, onChange }) {
|
|
47162
|
-
const [pendingAdd, setPendingAdd] = (0, react$1.useState)("");
|
|
47163
|
-
const visibleChoices = (0, react$1.useMemo)(() => {
|
|
47164
|
-
const filtered = clampToParent == null ? choices : choices.filter((c) => clampToParent.includes(c.value));
|
|
47165
|
-
const taken = new Set(value);
|
|
47166
|
-
return filtered.filter((c) => !taken.has(c.value));
|
|
47167
|
-
}, [
|
|
47168
|
-
choices,
|
|
47169
|
-
clampToParent,
|
|
47170
|
-
value
|
|
47171
|
-
]);
|
|
47172
|
-
const labelFor = (id) => choices.find((c) => c.value === id)?.label ?? `#${id}`;
|
|
47173
|
-
const handleAdd = (id) => {
|
|
47174
|
-
if (!id) return;
|
|
47175
|
-
onChange([...value, id]);
|
|
47176
|
-
setPendingAdd("");
|
|
47177
|
-
};
|
|
47178
|
-
const handleRemove = (id) => {
|
|
47179
|
-
onChange(value.filter((v) => v !== id));
|
|
47180
|
-
};
|
|
47181
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
47182
|
-
className: "flex-1 min-w-[160px] flex flex-wrap items-center gap-1",
|
|
47183
|
-
children: [
|
|
47184
|
-
value.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
47185
|
-
className: "text-[10px] text-foreground-subtle italic px-1",
|
|
47186
|
-
children: "no devices selected"
|
|
47187
|
-
}),
|
|
47188
|
-
value.map((id) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
47189
|
-
className: "inline-flex items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-mono text-primary",
|
|
47190
|
-
children: [labelFor(id), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
47191
|
-
type: "button",
|
|
47192
|
-
onClick: () => handleRemove(id),
|
|
47193
|
-
className: "hover:text-danger transition-colors",
|
|
47194
|
-
title: "Remove device",
|
|
47195
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(X, { className: "h-2.5 w-2.5" })
|
|
47196
|
-
})]
|
|
47197
|
-
}, id)),
|
|
47198
|
-
visibleChoices.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
|
|
47199
|
-
value: pendingAdd,
|
|
47200
|
-
onChange: (e) => handleAdd(e.target.value),
|
|
47201
|
-
className: "rounded border border-border bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-primary",
|
|
47202
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
|
|
47203
|
-
value: "",
|
|
47204
|
-
children: "+ add device…"
|
|
47205
|
-
}), visibleChoices.map((c) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
|
|
47206
|
-
value: c.value,
|
|
47207
|
-
children: c.label
|
|
47208
|
-
}, c.value))]
|
|
47209
|
-
})
|
|
47210
|
-
]
|
|
47211
|
-
});
|
|
47212
|
-
}
|
|
47213
47653
|
/**
|
|
47214
47654
|
* Validation helper. The wire schema (`TokenScopeSchema`) requires every
|
|
47215
|
-
* scope to carry a
|
|
47216
|
-
*
|
|
47655
|
+
* scope to carry a usable target and `access.length >= 1`; a v3 device
|
|
47656
|
+
* selector additionally requires a non-empty list for every kind except
|
|
47657
|
+
* `all`. Use this before passing the picker output to a mutate call so the
|
|
47217
47658
|
* user sees a clean inline error instead of a tRPC ZodError.
|
|
47659
|
+
*
|
|
47660
|
+
* An EMPTY selector list is rejected rather than silently treated as "all":
|
|
47661
|
+
* the schema's `.min(1)` would reject it server-side anyway, and the failure
|
|
47662
|
+
* mode of guessing wrong here is granting the whole fleet.
|
|
47218
47663
|
*/
|
|
47219
47664
|
function validateScopes(scopes) {
|
|
47220
47665
|
for (const s of scopes) {
|
|
47221
47666
|
if (s.type === "device") {
|
|
47222
|
-
|
|
47667
|
+
const problem = validateSelector(s.selector);
|
|
47668
|
+
if (problem !== null) return problem;
|
|
47223
47669
|
} else if (s.target.trim().length === 0) return `Scope ${s.type}: pick a target`;
|
|
47224
47670
|
if (s.access.length === 0) {
|
|
47225
|
-
const targetDesc = s.type === "device" ?
|
|
47671
|
+
const targetDesc = s.type === "device" ? describeDeviceSelector(s.selector) : s.target;
|
|
47226
47672
|
return `Scope ${s.type}:${targetDesc}: pick at least one access`;
|
|
47227
47673
|
}
|
|
47228
47674
|
}
|
|
47229
47675
|
return null;
|
|
47230
47676
|
}
|
|
47677
|
+
/** Non-empty check per selector kind. `all` selects the fleet and is always
|
|
47678
|
+
* complete. */
|
|
47679
|
+
function validateSelector(selector) {
|
|
47680
|
+
switch (selector.kind) {
|
|
47681
|
+
case "all": return null;
|
|
47682
|
+
case "ids": return selector.ids.length === 0 ? "Scope device: pick at least one device" : null;
|
|
47683
|
+
case "types": return selector.types.length === 0 ? "Scope device: pick at least one device type" : null;
|
|
47684
|
+
case "locations": return selector.locations.length === 0 ? "Scope device: pick at least one room" : null;
|
|
47685
|
+
}
|
|
47686
|
+
}
|
|
47231
47687
|
//#endregion
|
|
47232
47688
|
//#region src/contexts/zone-editing.tsx
|
|
47233
47689
|
/**
|
|
@@ -48153,8 +48609,10 @@ exports.DeviceItem = DeviceItem;
|
|
|
48153
48609
|
exports.DeviceList = DeviceList;
|
|
48154
48610
|
exports.DeviceMultiSelectField = DeviceMultiSelectField;
|
|
48155
48611
|
exports.DeviceSelectField = DeviceSelectField;
|
|
48612
|
+
exports.DeviceSelectorPicker = DeviceSelectorPicker;
|
|
48156
48613
|
exports.DeviceStepMatrix = DeviceStepMatrix;
|
|
48157
48614
|
exports.Dialog = Dialog;
|
|
48615
|
+
exports.DialogBody = DialogBody;
|
|
48158
48616
|
exports.DialogContent = DialogContent;
|
|
48159
48617
|
exports.DialogDescription = DialogDescription;
|
|
48160
48618
|
exports.DialogFooter = DialogFooter;
|
|
@@ -48298,6 +48756,7 @@ exports.SwitchHeroCard = SwitchHeroCard;
|
|
|
48298
48756
|
exports.SwitchInlineControl = SwitchInlineControl;
|
|
48299
48757
|
exports.SwitchPanel = SwitchPanel;
|
|
48300
48758
|
exports.SystemProvider = SystemProvider;
|
|
48759
|
+
exports.TABLE_SCROLL_MAX_H = TABLE_SCROLL_MAX_H;
|
|
48301
48760
|
exports.TEXT_FIELD_LABEL = TEXT_FIELD_LABEL;
|
|
48302
48761
|
exports.TEXT_HINT = TEXT_HINT;
|
|
48303
48762
|
exports.TEXT_METRIC = TEXT_METRIC;
|
|
@@ -48350,10 +48809,12 @@ exports.cursorFractionFor = cursorFractionFor;
|
|
|
48350
48809
|
exports.darkColors = require_theme_index.darkColors$1;
|
|
48351
48810
|
exports.defaultTheme = require_theme_index.defaultTheme$1;
|
|
48352
48811
|
exports.deriveDeviceKind = deriveDeviceKind;
|
|
48812
|
+
exports.describeDeviceSelector = describeDeviceSelector;
|
|
48353
48813
|
exports.deviceMatchesFilter = deviceMatchesFilter;
|
|
48354
48814
|
exports.deviceOptionLabel = deviceOptionLabel;
|
|
48355
48815
|
exports.deviceRoleMeta = deviceRoleMeta;
|
|
48356
48816
|
exports.deviceRoleMetaOf = deviceRoleMetaOf;
|
|
48817
|
+
exports.deviceTypeLabel = deviceTypeLabel;
|
|
48357
48818
|
exports.deviceTypeMeta = deviceTypeMeta;
|
|
48358
48819
|
exports.deviceTypeMetaOf = deviceTypeMetaOf;
|
|
48359
48820
|
exports.devicesToOptions = devicesToOptions;
|
|
@@ -48411,6 +48872,7 @@ exports.statusIcons = statusIcons;
|
|
|
48411
48872
|
exports.stepHasModelForFormat = stepHasModelForFormat;
|
|
48412
48873
|
exports.stepModelOptions = stepModelOptions;
|
|
48413
48874
|
exports.stripParentNamePrefix = stripParentNamePrefix;
|
|
48875
|
+
exports.tabForSelector = tabForSelector;
|
|
48414
48876
|
exports.tankAlert = tankAlert;
|
|
48415
48877
|
exports.themeToCss = require_theme_index.themeToCss$1;
|
|
48416
48878
|
exports.trpc = trpc;
|