tp-react-elements 3.0.14 → 3.0.16

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.
@@ -1,3 +1,3 @@
1
- var Month = {};
1
+ var InputRangeField = {};
2
2
 
3
- export { Month as __exports };
3
+ export { InputRangeField as __exports };
@@ -1,3 +1,3 @@
1
- var DateInput = {};
1
+ var Month = {};
2
2
 
3
- export { DateInput as __exports };
3
+ export { Month as __exports };
@@ -1,3 +1,3 @@
1
- var InputRangeField = {};
1
+ var DateInput = {};
2
2
 
3
- export { InputRangeField as __exports };
3
+ export { DateInput as __exports };
@@ -1,3 +1,3 @@
1
- var Calendar = {};
1
+ var DateRange = {};
2
2
 
3
- export { Calendar as __exports };
3
+ export { DateRange as __exports };
@@ -1,3 +1,3 @@
1
- var DateRangePicker = {};
1
+ var DefinedRange = {};
2
2
 
3
- export { DateRangePicker as __exports };
3
+ export { DefinedRange as __exports };
@@ -1,3 +1,3 @@
1
- var DefinedRange = {};
1
+ var DateRangePicker = {};
2
2
 
3
- export { DefinedRange as __exports };
3
+ export { DateRangePicker as __exports };
@@ -1,3 +1,3 @@
1
- var DateRange = {};
1
+ var Calendar = {};
2
2
 
3
- export { DateRange as __exports };
3
+ export { Calendar as __exports };
@@ -39,10 +39,34 @@ const FormRadioGroup = ({ props, variant }) => {
39
39
  fontSize: 16, // controls the radio icon size
40
40
  },
41
41
  }), [checkedColor]);
42
+ const isPill = props.item.radioVariant === 'pill';
42
43
  /**
43
44
  * Renders a group of radio buttons with labels and validation support
44
45
  */
45
- return (jsxs(Fragment, { children: [renderLabel(variant, props), jsx(FormControl, { component: "fieldset", children: jsx(Controller, { name: props.item.name, control: props.control, render: ({ field }) => (jsx(RadioGroup, { value: field.value || '', onChange: (e) => {
46
+ return (jsxs(Fragment, { children: [renderLabel(variant, props), jsx(FormControl, { component: "fieldset", children: jsx(Controller, { name: props.item.name, control: props.control, render: ({ field }) => isPill ? (jsx("div", { role: "radiogroup", "aria-label": props.item.label, style: { display: 'flex', gap: 7, flexWrap: 'wrap' }, children: props.item?.settings?.map((option, i) => {
47
+ const on = (field.value || '') === option.name;
48
+ const long = String(option.label).length > 26;
49
+ return (jsx("button", { type: "button", role: "radio", "aria-checked": on, onClick: () => {
50
+ field.onChange(option.name); // update form state
51
+ props.item.onChangeFn && props.item.onChangeFn(option.name); // optional handler
52
+ }, style: {
53
+ flex: long ? '1 1 100%' : '1 1 auto',
54
+ width: long ? '100%' : undefined,
55
+ textAlign: long ? 'left' : 'center',
56
+ minWidth: long ? undefined : 120,
57
+ padding: long ? '10px 13px' : '9px 12px',
58
+ borderRadius: 7,
59
+ fontFamily: 'inherit',
60
+ fontSize: '12.5px',
61
+ fontWeight: on ? 700 : 600,
62
+ cursor: 'pointer',
63
+ whiteSpace: long ? 'normal' : 'nowrap',
64
+ lineHeight: 1.4,
65
+ border: `1px solid ${on ? checkedColor : '#DDE4ED'}`,
66
+ background: on ? `${checkedColor}14` : '#fff',
67
+ color: on ? checkedColor : '#42536B',
68
+ }, children: option.label }, i));
69
+ }) })) : (jsx(RadioGroup, { value: field.value || '', onChange: (e) => {
46
70
  field.onChange(e.target.value); // update form state
47
71
  props.item.onChangeFn && props.item.onChangeFn(e.target.value); // optional handler
48
72
  }, row: true, sx: RADIO_GROUP_SX, children: props.item?.settings?.map((option, i) => (jsx(FormControlLabel, { value: option.name, control: jsx(Radio, { sx: radioSx }), label: jsx(Typography, { fontSize: '11px', fontWeight: 'normal !important', children: option.label }) }, i))) })) }) }), jsx(FormBottomField, { ...props })] }));
@@ -37,10 +37,15 @@ export interface GenericPaginatedGridProps<TData extends object> {
37
37
  */
38
38
  columns: TPColumnDef<TData>[];
39
39
  /**
40
- * Async function called on every page/filter change.
41
- * Receives the current flat filter map and the 0-based page index.
40
+ * Async function called on every page/filter/page-size change.
41
+ * Receives the current flat filter map, the 0-based page index, and the
42
+ * current `pageSize` (whatever `pageSize`/`pageSizeOptions`/`batchSize`
43
+ * resolved to, including the user's latest "Rows per page" selection) so
44
+ * the request can ask the backend for exactly that many rows. Existing
45
+ * implementations that only declare the first two parameters keep working
46
+ * unchanged — the third argument is simply ignored by them, same as before.
42
47
  */
43
- fetchPage: (filters: Record<string, string>, pageIndex: number) => Promise<PagedResult<TData>>;
48
+ fetchPage: (filters: Record<string, string>, pageIndex: number, pageSize: number) => Promise<PagedResult<TData>>;
44
49
  /** Base react-query cache key. */
45
50
  queryKey: unknown[];
46
51
  /** Server-side filter chip definitions. */
@@ -123,12 +128,15 @@ export interface GenericPaginatedGridProps<TData extends object> {
123
128
  * Initial "Rows per page" value, shown in the footer before the user touches the dropdown.
124
129
  *
125
130
  * The grid never slices rows client-side (`manualPagination: true`, no row model) — whatever
126
- * `fetchPage` resolves with for a page **is** that page, shown in full. `pageSize` only feeds
127
- * the footer's count text and the Next-page button's math, so it must match how many rows
128
- * `fetchPage` actually returns per call, same as `batchSize` always had to. The two exist
129
- * separately only because `batchSize` doubles as "rows per fetch" documentation elsewhere;
130
- * use `pageSize` when you want the initial dropdown value to be authoritative without
131
- * reinterpreting `batchSize`'s meaning at every call site.
131
+ * `fetchPage` resolves with for a page **is** that page, shown in full. The current value
132
+ * (initial `pageSize`, or whatever the user picks from the footer dropdown afterward) is
133
+ * passed to `fetchPage` as its third argument and is part of the react-query cache key, so
134
+ * picking a new value re-fetches that page at the new size — `fetchPage` should ask the
135
+ * backend for that many rows per call. Implementations written before this argument existed
136
+ * simply ignore it and keep behaving exactly as before. The two exist separately only because
137
+ * `batchSize` doubles as "rows per fetch" documentation elsewhere; use `pageSize` when you
138
+ * want the initial dropdown value to be authoritative without reinterpreting `batchSize`'s
139
+ * meaning at every call site.
132
140
  *
133
141
  * Default: `batchSize` (unchanged behaviour — identical to how the initial page size worked
134
142
  * before this prop existed). If this value isn't already in `pageSizeOptions`, it's folded in
@@ -466,8 +466,8 @@ function GenericPaginatedGrid({ columns, fetchPage, queryKey, serverFilterDefs,
466
466
  const resolvedHeight = tableHeight || (shouldAutoFill ? autoHeight : undefined) || tableMinHeight;
467
467
  // ── Data fetching ──────────────────────────────────────────────────────────
468
468
  const { data, isLoading, isError, isFetching, refetch } = useQuery({
469
- queryKey: [...queryKey, filterValues, pagination.pageIndex],
470
- queryFn: () => fetchPage(filterValues, pagination.pageIndex),
469
+ queryKey: [...queryKey, filterValues, pagination.pageIndex, pagination.pageSize],
470
+ queryFn: () => fetchPage(filterValues, pagination.pageIndex, pagination.pageSize),
471
471
  placeholderData: keepPreviousData,
472
472
  staleTime: 30000,
473
473
  });
@@ -1,4 +1,4 @@
1
- import { __exports as Calendar } from '../../../../../_virtual/index.esm4.js';
1
+ import { __exports as Calendar } from '../../../../../_virtual/index.esm7.js';
2
2
  import require$$0__default from 'react';
3
3
  import { __require as requirePropTypes } from '../../../../prop-types/index.esm.js';
4
4
  import { __require as requireDayCell } from '../DayCell/index.esm.js';
@@ -1,4 +1,4 @@
1
- import { __exports as DateInput } from '../../../../../_virtual/index.esm13.js';
1
+ import { __exports as DateInput } from '../../../../../_virtual/index.esm14.js';
2
2
  import require$$0__default from 'react';
3
3
  import { __require as requirePropTypes } from '../../../../prop-types/index.esm.js';
4
4
  import { __require as requireClassnames } from '../../../../classnames/index.esm.js';
@@ -1,4 +1,4 @@
1
- import { __exports as DateRange } from '../../../../../_virtual/index.esm7.js';
1
+ import { __exports as DateRange } from '../../../../../_virtual/index.esm4.js';
2
2
  import require$$0__default from 'react';
3
3
  import { __require as requirePropTypes } from '../../../../prop-types/index.esm.js';
4
4
  import { __require as requireCalendar } from '../Calendar/index.esm.js';
@@ -1,4 +1,4 @@
1
- import { __exports as DateRangePicker } from '../../../../../_virtual/index.esm5.js';
1
+ import { __exports as DateRangePicker } from '../../../../../_virtual/index.esm6.js';
2
2
  import require$$0__default from 'react';
3
3
  import { __require as requirePropTypes } from '../../../../prop-types/index.esm.js';
4
4
  import { __require as requireDateRange } from '../DateRange/index.esm.js';
@@ -1,4 +1,4 @@
1
- import { __exports as DefinedRange } from '../../../../../_virtual/index.esm6.js';
1
+ import { __exports as DefinedRange } from '../../../../../_virtual/index.esm5.js';
2
2
  import require$$0__default from 'react';
3
3
  import { __require as requirePropTypes } from '../../../../prop-types/index.esm.js';
4
4
  import { __require as requireStyles } from '../../styles.esm.js';
@@ -1,4 +1,4 @@
1
- import { __exports as InputRangeField } from '../../../../../_virtual/index.esm14.js';
1
+ import { __exports as InputRangeField } from '../../../../../_virtual/index.esm12.js';
2
2
  import require$$0__default from 'react';
3
3
  import { __require as requirePropTypes } from '../../../../prop-types/index.esm.js';
4
4
 
@@ -1,4 +1,4 @@
1
- import { __exports as Month } from '../../../../../_virtual/index.esm12.js';
1
+ import { __exports as Month } from '../../../../../_virtual/index.esm13.js';
2
2
  import require$$0__default from 'react';
3
3
  import { __require as requirePropTypes } from '../../../../prop-types/index.esm.js';
4
4
  import { __require as requireDayCell } from '../DayCell/index.esm.js';
@@ -97,6 +97,13 @@ export interface FormSectionPropsItem {
97
97
  labelVariant?: 'floating' | 'static';
98
98
  /** Style overrides for the label span when `labelVariant: 'static'` is used. */
99
99
  labelSx?: SxProps<Theme>;
100
+ /**
101
+ * Visual style for `radio-group` fields. `'dot'` (default when omitted) keeps every existing
102
+ * consumer's current look — a stock MUI `Radio` dot per option. `'pill'` renders each option
103
+ * as a bordered, labelled button instead (no dot) that fills with the theme's
104
+ * `TPRadioGroup.checkedColor` when selected — no visual change unless a field opts in.
105
+ */
106
+ radioVariant?: 'dot' | 'pill';
100
107
  }
101
108
  export type VariantProps = 'standard' | 'outlined' | '';
102
109
  export interface FormRenderProps {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tp-react-elements",
3
- "version": "3.0.14",
3
+ "version": "3.0.16",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "React form components library built with React Hook Form and Yup",
@@ -66,6 +66,8 @@
66
66
  "format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,css,scss,md,json}\"",
67
67
  "storybook": "storybook dev -p 6006",
68
68
  "build-storybook": "storybook build",
69
+ "deploy-storybook": "powershell -ExecutionPolicy Bypass -File scripts/deploy-storybook.ps1",
70
+ "deploy-storybook:ftp": "powershell -ExecutionPolicy Bypass -File scripts/deploy-storybook-ftp.ps1",
69
71
  "version:patch": "npm version patch",
70
72
  "version:minor": "npm version minor",
71
73
  "version:major": "npm version major",
@@ -0,0 +1,173 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Builds Storybook and publishes storybook-static/ to the SmarterASP.NET site
4
+ "tpproduction-001-site41" over FTP, using the same credentials as
5
+ site41.PublishSettings' FTP profile. Use this if Web Deploy
6
+ (deploy-storybook.ps1) keeps failing with ERROR_CONNECTION_TERMINATED --
7
+ shared hosts often cap the size/duration of a single Web Deploy sync, and
8
+ this build's minified JS chunks exceed that. FTP uploads file-by-file, so a
9
+ large build doesn't hit the same limit.
10
+
11
+ The password is read from .storybook-deploy.local.json in the repo root if
12
+ present (shared with deploy-storybook.ps1, gitignored). Otherwise you're
13
+ prompted once and it's saved there for next time.
14
+
15
+ .EXAMPLE
16
+ ./scripts/deploy-storybook-ftp.ps1
17
+
18
+ .EXAMPLE
19
+ ./scripts/deploy-storybook-ftp.ps1 -SkipBuild
20
+ #>
21
+
22
+ param(
23
+ [string]$FtpHost = "win8095.site4now.net",
24
+ [int]$FtpPort = 21,
25
+ [string]$RemoteRoot = "/tp-react-elements",
26
+ [string]$UserName = "tpproduction-001",
27
+ [SecureString]$Password,
28
+ [switch]$SkipBuild
29
+ )
30
+
31
+ $ErrorActionPreference = "Stop"
32
+ $repoRoot = Split-Path -Parent $PSScriptRoot
33
+ $sourcePath = Join-Path $repoRoot "storybook-static"
34
+ $credentialFile = Join-Path $repoRoot ".storybook-deploy.local.json"
35
+
36
+ if (-not $SkipBuild) {
37
+ Write-Host "Building Storybook..." -ForegroundColor Cyan
38
+ Push-Location $repoRoot
39
+ try {
40
+ npm run build-storybook
41
+ if ($LASTEXITCODE -ne 0) { throw "build-storybook failed with exit code $LASTEXITCODE" }
42
+ } finally {
43
+ Pop-Location
44
+ }
45
+ }
46
+
47
+ if (-not (Test-Path $sourcePath)) {
48
+ throw "Build output not found at $sourcePath"
49
+ }
50
+
51
+ $plainPassword = $null
52
+ $passwordFromFile = $false
53
+
54
+ if ($Password) {
55
+ $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Password)
56
+ try { $plainPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) }
57
+ finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
58
+ } elseif (Test-Path $credentialFile) {
59
+ $plainPassword = (Get-Content $credentialFile -Raw | ConvertFrom-Json).password
60
+ $passwordFromFile = $true
61
+ Write-Host "Using saved deployment password from .storybook-deploy.local.json" -ForegroundColor DarkGray
62
+ }
63
+
64
+ if (-not $plainPassword) {
65
+ $securePassword = Read-Host -Prompt "FTP password for $UserName" -AsSecureString
66
+ $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePassword)
67
+ try { $plainPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) }
68
+ finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
69
+ }
70
+
71
+ if (-not $passwordFromFile) {
72
+ @{ password = $plainPassword } | ConvertTo-Json | Set-Content -Path $credentialFile -Encoding utf8
73
+ Write-Host "Saved password to .storybook-deploy.local.json for next time (gitignored)." -ForegroundColor DarkGray
74
+ }
75
+
76
+ $credential = New-Object System.Net.NetworkCredential($UserName, $plainPassword)
77
+
78
+ function New-RemoteDirectory([string]$remotePath) {
79
+ $uri = "ftp://$FtpHost`:$FtpPort$remotePath"
80
+ $request = [System.Net.FtpWebRequest]::Create($uri)
81
+ $request.Credentials = $credential
82
+ $request.UsePassive = $true
83
+ $request.UseBinary = $true
84
+ $request.KeepAlive = $false
85
+ $request.Method = [System.Net.WebRequestMethods+Ftp]::MakeDirectory
86
+ try {
87
+ $response = $request.GetResponse()
88
+ $response.Close()
89
+ } catch [System.Net.WebException] {
90
+ # 550 = already exists, which is fine
91
+ $resp = $_.Exception.Response
92
+ if (-not ($resp -and $resp.StatusDescription -match "550")) {
93
+ throw
94
+ }
95
+ }
96
+ }
97
+
98
+ function Send-RemoteFile([string]$localFile, [string]$remotePath) {
99
+ $uri = "ftp://$FtpHost`:$FtpPort$remotePath"
100
+ $request = [System.Net.FtpWebRequest]::Create($uri)
101
+ $request.Credentials = $credential
102
+ $request.UsePassive = $true
103
+ $request.UseBinary = $true
104
+ $request.KeepAlive = $false
105
+ $request.Method = [System.Net.WebRequestMethods+Ftp]::UploadFile
106
+
107
+ $bytes = [System.IO.File]::ReadAllBytes($localFile)
108
+ $request.ContentLength = $bytes.Length
109
+ $stream = $request.GetRequestStream()
110
+ try {
111
+ $stream.Write($bytes, 0, $bytes.Length)
112
+ } finally {
113
+ $stream.Close()
114
+ }
115
+ $response = $request.GetResponse()
116
+ $response.Close()
117
+ }
118
+
119
+ Write-Host "Publishing $sourcePath -> ftp://$FtpHost$RemoteRoot ..." -ForegroundColor Cyan
120
+
121
+ $allFiles = Get-ChildItem -Path $sourcePath -Recurse -File
122
+ $createdDirs = New-Object System.Collections.Generic.HashSet[string]
123
+ $total = $allFiles.Count
124
+ $count = 0
125
+ $failed = @()
126
+
127
+ foreach ($file in $allFiles) {
128
+ $count++
129
+ $relative = $file.FullName.Substring($sourcePath.Length).TrimStart("\", "/").Replace("\", "/")
130
+ $remoteFilePath = "$RemoteRoot/$relative"
131
+ $remoteDir = [System.IO.Path]::GetDirectoryName($remoteFilePath).Replace("\", "/")
132
+
133
+ if ($remoteDir -and -not $createdDirs.Contains($remoteDir)) {
134
+ $segments = $remoteDir.Trim("/").Split("/")
135
+ $accum = ""
136
+ foreach ($segment in $segments) {
137
+ $accum = "$accum/$segment"
138
+ if (-not $createdDirs.Contains($accum)) {
139
+ New-RemoteDirectory $accum
140
+ $createdDirs.Add($accum) | Out-Null
141
+ }
142
+ }
143
+ }
144
+
145
+ $attempt = 0
146
+ $ok = $false
147
+ while (-not $ok -and $attempt -lt 3) {
148
+ $attempt++
149
+ try {
150
+ Send-RemoteFile $file.FullName $remoteFilePath
151
+ $ok = $true
152
+ } catch {
153
+ if ($attempt -ge 3) {
154
+ $failed += $relative
155
+ Write-Host "FAILED ($attempt/3): $relative - $($_.Exception.Message)" -ForegroundColor Red
156
+ } else {
157
+ Start-Sleep -Seconds 2
158
+ }
159
+ }
160
+ }
161
+
162
+ if ($ok) {
163
+ Write-Host "[$count/$total] $relative" -ForegroundColor DarkGray
164
+ }
165
+ }
166
+
167
+ if ($failed.Count -gt 0) {
168
+ Write-Host "`n$($failed.Count) file(s) failed to upload:" -ForegroundColor Red
169
+ $failed | ForEach-Object { Write-Host " $_" -ForegroundColor Red }
170
+ throw "FTP deploy finished with $($failed.Count) failure(s)."
171
+ }
172
+
173
+ Write-Host "`nDeployed successfully: $total files." -ForegroundColor Green
@@ -0,0 +1,135 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Builds Storybook and publishes storybook-static/ to the SmarterASP.NET site
4
+ "tpproduction-001-site41" via Web Deploy (MSDeploy), using the same Web Deploy
5
+ mechanism as site41.PublishSettings.
6
+
7
+ The deployment password is read from .storybook-deploy.local.json in the repo
8
+ root if that file exists (it is gitignored, so it never leaves this machine).
9
+ Otherwise you are prompted for it once, and it is saved to that file so future
10
+ runs don't ask again. On a fresh clone the file won't exist yet, so the first
11
+ run there will prompt.
12
+
13
+ .EXAMPLE
14
+ ./scripts/deploy-storybook.ps1
15
+ (uses the saved password, or prompts and saves it on first run)
16
+
17
+ .EXAMPLE
18
+ ./scripts/deploy-storybook.ps1 -SkipBuild
19
+ (redeploys the existing storybook-static/ folder without rebuilding)
20
+ #>
21
+
22
+ param(
23
+ [string]$SiteName = "tpproduction-001-site41",
24
+ [string]$ComputerName = "https://win8095.site4now.net:8172/msdeploy.axd?site=tpproduction-001-site41",
25
+ [string]$UserName = "tpproduction-001",
26
+ [SecureString]$Password,
27
+ [switch]$SkipBuild
28
+ )
29
+
30
+ $ErrorActionPreference = "Stop"
31
+ $repoRoot = Split-Path -Parent $PSScriptRoot
32
+ $sourcePath = Join-Path $repoRoot "storybook-static"
33
+ $credentialFile = Join-Path $repoRoot ".storybook-deploy.local.json"
34
+
35
+ $msdeployCandidates = @(
36
+ "$env:ProgramFiles\IIS\Microsoft Web Deploy V3\msdeploy.exe",
37
+ "${env:ProgramFiles(x86)}\IIS\Microsoft Web Deploy V3\msdeploy.exe"
38
+ )
39
+ $msdeploy = $msdeployCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
40
+ if (-not $msdeploy) {
41
+ throw "msdeploy.exe not found. Install 'Web Deploy 3.6' from Microsoft and re-run."
42
+ }
43
+
44
+ if (-not $SkipBuild) {
45
+ Write-Host "Building Storybook..." -ForegroundColor Cyan
46
+ Push-Location $repoRoot
47
+ try {
48
+ npm run build-storybook
49
+ if ($LASTEXITCODE -ne 0) { throw "build-storybook failed with exit code $LASTEXITCODE" }
50
+ } finally {
51
+ Pop-Location
52
+ }
53
+ }
54
+
55
+ if (-not (Test-Path $sourcePath)) {
56
+ throw "Build output not found at $sourcePath"
57
+ }
58
+
59
+ $plainPassword = $null
60
+ $passwordFromFile = $false
61
+
62
+ if ($Password) {
63
+ $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Password)
64
+ try {
65
+ $plainPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
66
+ } finally {
67
+ [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
68
+ }
69
+ } elseif (Test-Path $credentialFile) {
70
+ $plainPassword = (Get-Content $credentialFile -Raw | ConvertFrom-Json).password
71
+ $passwordFromFile = $true
72
+ Write-Host "Using saved deployment password from .storybook-deploy.local.json" -ForegroundColor DarkGray
73
+ }
74
+
75
+ if (-not $plainPassword) {
76
+ $securePassword = Read-Host -Prompt "Web Deploy password for $UserName" -AsSecureString
77
+ $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePassword)
78
+ try {
79
+ $plainPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
80
+ } finally {
81
+ [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
82
+ }
83
+ }
84
+
85
+ if (-not $passwordFromFile) {
86
+ @{ password = $plainPassword } | ConvertTo-Json | Set-Content -Path $credentialFile -Encoding utf8
87
+ Write-Host "Saved password to .storybook-deploy.local.json for next time (gitignored)." -ForegroundColor DarkGray
88
+ }
89
+
90
+ Write-Host "Publishing $sourcePath -> $SiteName via Web Deploy..." -ForegroundColor Cyan
91
+
92
+ # Synced one file at a time (instead of the whole storybook-static folder in a
93
+ # single sync) because this build is ~10MB across 150+ files, and one big sync
94
+ # kept dying with ERROR_CONNECTION_TERMINATED partway through -- SmarterASP's
95
+ # shared-hosting Web Deploy endpoint appears to cap how long/large a single
96
+ # request can be. Each file here is well under 1.5MB, small enough to finish
97
+ # before any such limit kicks in. contentPath's provider auto-creates any
98
+ # destination folders that don't exist yet, so no separate mkdir pass is needed.
99
+ $allFiles = Get-ChildItem -Path $sourcePath -Recurse -File
100
+ $total = $allFiles.Count
101
+ $count = 0
102
+ $failed = @()
103
+
104
+ foreach ($file in $allFiles) {
105
+ $count++
106
+ $relative = $file.FullName.Substring($sourcePath.Length).TrimStart("\", "/").Replace("\", "/")
107
+ $remoteFilePath = "$SiteName/$relative"
108
+
109
+ # Pre-quoted as strings (not inline) so PowerShell's native-argument
110
+ # marshalling preserves the embedded quotes around paths that contain
111
+ # spaces (e.g. "D:\TP Projects\..."). Writing the quotes inline in the
112
+ # call below causes PowerShell to strip them before msdeploy ever sees
113
+ # them, which then misparses the whole descriptor as an unknown provider.
114
+ $sourceArg = "-source:contentPath=`"$($file.FullName)`""
115
+ $destArg = "-dest:contentPath=`"$remoteFilePath`",ComputerName=`"$ComputerName`",UserName=`"$UserName`",Password=`"$plainPassword`",AuthType=`"Basic`""
116
+
117
+ $output = & $msdeploy -verb:sync $sourceArg $destArg -allowUntrusted -retryAttempts:2 -retryInterval:5000 2>&1
118
+ if ($LASTEXITCODE -eq 0) {
119
+ Write-Host "[$count/$total] $relative" -ForegroundColor DarkGray
120
+ } else {
121
+ $failed += $relative
122
+ Write-Host "[$count/$total] FAILED: $relative" -ForegroundColor Red
123
+ $output | ForEach-Object { Write-Host " $_" -ForegroundColor DarkRed }
124
+ }
125
+ }
126
+
127
+ $plainPassword = $null
128
+
129
+ if ($failed.Count -gt 0) {
130
+ Write-Host "`n$($failed.Count) file(s) failed to deploy:" -ForegroundColor Red
131
+ $failed | ForEach-Object { Write-Host " $_" -ForegroundColor Red }
132
+ throw "Web Deploy finished with $($failed.Count) failure(s)."
133
+ }
134
+
135
+ Write-Host "`nDeployed successfully: $total files." -ForegroundColor Green