@rahul_vendure/vendure-plugin-channel-product-availability 1.0.0 → 1.1.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0
4
+
5
+ ### Changed
6
+
7
+ - Redesigned the Dashboard "Channel availability" block:
8
+ - each channel has a **Global / On / Off** segmented control; Global shows what it currently resolves to
9
+ - an "Available in X of Y channels" summary and a global-status pill
10
+ - callouts when the product is globally disabled but still sold somewhere, or globally enabled but hidden somewhere
11
+ - changes apply instantly and roll back if the server rejects them; toasts name the product and channel
12
+ - readable channel names ("Default channel"), a "Current" marker, and view-only rows (with a lock icon) for channels where the admin lacks update permission
13
+ - skeleton loading and styled permission/error states; refreshes when the product's own Enabled switch is saved
14
+
15
+ ### Fixed
16
+
17
+ - Dashboard on Vendure 3.8: the "Unavailable" badge used `variant="secondary"`, which no longer exists in `@vendure-io/ui` 2.x and rendered unstyled. The block now uses only primitives shared by `@vendure-io/ui` 1.x and 2.x.
18
+
3
19
  ## 1.0.0
4
20
 
5
21
  Initial release.
package/README.md CHANGED
@@ -57,15 +57,14 @@ Indexes: `UNIQUE (productId, channelId)` and `(channelId, enabled)`.
57
57
 
58
58
  ### Dashboard
59
59
 
60
- The React Dashboard extension is discovered automatically through the plugin's `dashboard` metadata. It adds a **Channel availability** block to the product detail page. For each channel the product is assigned to, the block shows:
60
+ The React Dashboard extension is discovered automatically through the plugin's `dashboard` metadata. It adds a **Channel availability** block to the product detail page:
61
61
 
62
- - whether the product is available there
63
- - whether that comes from the global flag or from an override
64
- - buttons to enable, disable, or go back to the global flag (**Use global**)
62
+ - a **Global / On / Off** control per channel. *Global* follows the product's own Enabled switch; *On* and *Off* override it for that channel only.
63
+ - an "Available in X of Y channels" summary and a callout when the global switch and a channel disagree (e.g. "Disabled globally, but still sold in UK")
64
+ - changes apply instantly and roll back if the server rejects them
65
+ - channels where the admin has read but not update permission are shown as **view only**; channels the admin can't read at all are not listed
65
66
 
66
- The block also warns when a product is disabled globally but explicitly enabled somewhere. Channels where the admin can't update show as read-only.
67
-
68
- The backend works without the Dashboard.
67
+ Rendered and click-tested in light and dark themes on Vendure 3.6 (`@vendure-io/ui` 1.x). It uses only components whose API is identical in `@vendure-io/ui` 2.x (Vendure 3.8), but it has not been rendered on 3.8 yet. The backend works without the Dashboard.
69
68
 
70
69
  ## Options
71
70
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rahul_vendure/vendure-plugin-channel-product-availability",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Per-channel product enable/disable overrides for Vendure, enforced across catalog, search, cart and checkout",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -1,5 +1,7 @@
1
1
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2
- import { api, Badge, Button, useAuth } from '@vendure/dashboard';
2
+ import { api, ChannelCodeLabel, Skeleton, useAuth, useChannel } from '@vendure/dashboard';
3
+ import { AlertTriangle, Eye, EyeOff, Globe, Info, Loader2, Lock } from 'lucide-react';
4
+ import type { ReactNode } from 'react';
3
5
  import { toast } from 'sonner';
4
6
 
5
7
  import {
@@ -8,163 +10,347 @@ import {
8
10
  setProductChannelAvailabilityMutation,
9
11
  } from './channel-availability.graphql';
10
12
 
13
+ // Only primitives whose API is identical in @vendure-io/ui 1.x (Vendure <=3.7) and
14
+ // 2.x (3.8+) are used; status pills and the segmented control are plain elements.
15
+
11
16
  const UPDATE_PERMISSIONS = ['UpdateCatalog', 'UpdateProduct'];
12
17
 
18
+ type OverrideState = 'INHERITED' | 'ENABLED' | 'DISABLED';
19
+
13
20
  type Row = {
14
21
  channelId: string;
15
22
  channelCode: string;
16
23
  globalEnabled: boolean;
17
24
  override: boolean | null;
18
- overrideState: 'INHERITED' | 'ENABLED' | 'DISABLED';
25
+ overrideState: OverrideState;
19
26
  effectiveEnabled: boolean;
20
27
  };
21
28
 
29
+ type Change = { channelId: string; channelCode: string; next: OverrideState };
30
+
31
+ function cx(...classes: Array<string | false | null | undefined>) {
32
+ return classes.filter(Boolean).join(' ');
33
+ }
34
+
22
35
  function isForbidden(error: unknown) {
23
36
  const message = error instanceof Error ? error.message : String(error);
24
37
  return /not currently authorized|FORBIDDEN/i.test(message);
25
38
  }
26
39
 
40
+ function applyChange(row: Row, next: OverrideState): Row {
41
+ const override = next === 'INHERITED' ? null : next === 'ENABLED';
42
+ return {
43
+ ...row,
44
+ override,
45
+ overrideState: next,
46
+ effectiveEnabled: override ?? row.globalEnabled,
47
+ };
48
+ }
49
+
27
50
  /**
28
- * Per-channel availability of the product shown on the product detail page.
29
- * Each action only changes the override for that one channel; the product's
51
+ * Per-channel availability of the product on the product detail page.
52
+ *
53
+ * Each control only changes the override for that one channel; the product's
30
54
  * global "Enabled" switch is never modified here.
31
55
  */
32
- export function ChannelAvailabilityBlock({ productId }: { productId: string }) {
56
+ export function ChannelAvailabilityBlock({
57
+ productId,
58
+ productName,
59
+ globalEnabledHint,
60
+ }: {
61
+ productId: string;
62
+ productName?: string;
63
+ /** The product's saved `enabled` value; refetches when it changes. */
64
+ globalEnabledHint?: boolean;
65
+ }) {
33
66
  const queryClient = useQueryClient();
34
67
  const { channels } = useAuth();
35
- const queryKey = ['productChannelAvailabilities', productId];
68
+ const { activeChannel } = useChannel();
69
+ const queryKey = ['productChannelAvailabilities', productId, globalEnabledHint];
36
70
 
37
71
  const { data, isLoading, error } = useQuery({
38
72
  queryKey,
39
73
  queryFn: () => api.query(productChannelAvailabilitiesQuery, { productId }),
40
74
  });
41
75
 
42
- const onSuccess = () => queryClient.invalidateQueries({ queryKey });
43
- const onError = (err: unknown) =>
44
- toast.error('Could not update channel availability', {
45
- description: err instanceof Error ? err.message : 'Unknown error',
46
- });
47
-
48
- const setOverride = useMutation({
49
- mutationFn: (input: { channelId: string; enabled: boolean }) =>
50
- api.mutate(setProductChannelAvailabilityMutation, { input: { productId, ...input } }),
51
- onSuccess,
52
- onError,
53
- });
54
- const clearOverride = useMutation({
55
- mutationFn: (input: { channelId: string }) =>
56
- api.mutate(clearProductChannelAvailabilityMutation, { input: { productId, ...input } }),
57
- onSuccess,
58
- onError,
76
+ const mutation = useMutation<unknown, unknown, Change, { previous: unknown }>({
77
+ mutationFn: ({ channelId, next }: Change): Promise<unknown> =>
78
+ next === 'INHERITED'
79
+ ? api.mutate(clearProductChannelAvailabilityMutation, { input: { productId, channelId } })
80
+ : api.mutate(setProductChannelAvailabilityMutation, {
81
+ input: { productId, channelId, enabled: next === 'ENABLED' },
82
+ }),
83
+ // Optimistic: flip the row immediately, roll back if the server rejects it.
84
+ onMutate: async ({ channelId, next }) => {
85
+ await queryClient.cancelQueries({ queryKey });
86
+ const previous = queryClient.getQueryData<any>(queryKey);
87
+ queryClient.setQueryData<any>(queryKey, (old: any) =>
88
+ old
89
+ ? {
90
+ ...old,
91
+ productChannelAvailabilities: old.productChannelAvailabilities.map((r: Row) =>
92
+ r.channelId === channelId ? applyChange(r, next) : r,
93
+ ),
94
+ }
95
+ : old,
96
+ );
97
+ return { previous };
98
+ },
99
+ onError: (err, { channelCode }, context) => {
100
+ queryClient.setQueryData(queryKey, context?.previous);
101
+ toast.error(`Could not update ${channelLabel(channelCode)}`, {
102
+ description: isForbidden(err)
103
+ ? 'You do not have permission to change availability in this channel.'
104
+ : err instanceof Error
105
+ ? err.message
106
+ : 'Unknown error',
107
+ });
108
+ },
109
+ onSuccess: (_result, { channelCode, next }) => {
110
+ const subject = productName ?? 'Product';
111
+ const where = channelLabel(channelCode);
112
+ toast.success(
113
+ next === 'INHERITED'
114
+ ? `${subject} now follows the global status in ${where}`
115
+ : `${subject} is now ${next === 'ENABLED' ? 'available' : 'hidden'} in ${where}`,
116
+ );
117
+ },
118
+ onSettled: () => queryClient.invalidateQueries({ queryKey: ['productChannelAvailabilities', productId] }),
59
119
  });
60
- const busy = setOverride.isPending || clearOverride.isPending;
61
120
 
62
121
  const canUpdateIn = (channelId: string) => {
63
- const permissions = channels?.find(c => c.id === channelId)?.permissions ?? [];
64
- return UPDATE_PERMISSIONS.some(p => (permissions as string[]).includes(p));
122
+ const permissions = (channels?.find(c => c.id === channelId)?.permissions ?? []) as string[];
123
+ return UPDATE_PERMISSIONS.some(p => permissions.includes(p));
65
124
  };
66
125
 
67
126
  if (isLoading) {
68
127
  return (
69
- <div className="space-y-2" aria-busy="true">
70
- <div className="h-10 animate-pulse rounded-md bg-muted" />
71
- <div className="h-10 animate-pulse rounded-md bg-muted" />
128
+ <div className="space-y-3" aria-busy="true" aria-label="Loading channel availability">
129
+ <Skeleton className="h-4 w-40" />
130
+ <Skeleton className="h-[88px] w-full rounded-lg" />
131
+ <Skeleton className="h-[88px] w-full rounded-lg" />
72
132
  </div>
73
133
  );
74
134
  }
135
+
75
136
  if (error) {
137
+ const forbidden = isForbidden(error);
76
138
  return (
77
- <p className="text-sm text-destructive" role="alert">
78
- {isForbidden(error)
79
- ? 'You do not have permission to view channel availability for this product.'
80
- : `Could not load channel availability: ${error instanceof Error ? error.message : String(error)}`}
81
- </p>
139
+ <div
140
+ role="alert"
141
+ className="flex gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"
142
+ >
143
+ {forbidden ? <Lock className="mt-0.5 h-4 w-4 shrink-0" /> : <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />}
144
+ <span>
145
+ {forbidden
146
+ ? 'You do not have permission to view channel availability for this product.'
147
+ : `Could not load channel availability. ${error instanceof Error ? error.message : ''}`}
148
+ </span>
149
+ </div>
82
150
  );
83
151
  }
152
+
84
153
  const rows = (data?.productChannelAvailabilities ?? []) as Row[];
85
154
  if (!rows.length) {
86
155
  return (
87
- <p className="text-sm text-muted-foreground">
156
+ <div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
88
157
  This product is not assigned to any channel you can manage.
89
- </p>
158
+ </div>
90
159
  );
91
160
  }
92
161
 
93
162
  const globalEnabled = rows[0].globalEnabled;
163
+ const availableCount = rows.filter(r => r.effectiveEnabled).length;
94
164
  const enabledDespiteGlobal = rows.filter(r => !r.globalEnabled && r.overrideState === 'ENABLED');
165
+ const hiddenDespiteGlobal = rows.filter(r => r.globalEnabled && r.overrideState === 'DISABLED');
95
166
 
96
167
  return (
97
168
  <div className="space-y-3">
98
- <p className="text-xs text-muted-foreground">
99
- Global status: <strong>{globalEnabled ? 'Enabled' : 'Disabled'}</strong>. Channels without an
100
- override follow it; an override always wins.
101
- </p>
102
- {enabledDespiteGlobal.length > 0 && (
103
- <p className="rounded-md border border-warning-border bg-warning/10 p-2 text-xs text-warning">
104
- This product is disabled globally but explicitly enabled in{' '}
105
- {enabledDespiteGlobal.map(r => r.channelCode).join(', ')}. Customers there can find and buy it.
169
+ <div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1">
170
+ <p className="text-sm text-muted-foreground">
171
+ Available in <span className="font-semibold text-foreground">{availableCount}</span> of{' '}
172
+ {rows.length} channel{rows.length === 1 ? '' : 's'}
106
173
  </p>
174
+ <span
175
+ className={cx(
176
+ 'inline-flex items-center gap-1 whitespace-nowrap rounded-full border px-2 py-0.5 text-[11px] font-medium',
177
+ globalEnabled ? 'border-success/30 text-success' : 'text-muted-foreground',
178
+ )}
179
+ title="The product's own Enabled switch. Channels set to Global follow it."
180
+ >
181
+ <Globe className="h-3 w-3" />
182
+ Global {globalEnabled ? 'on' : 'off'}
183
+ </span>
184
+ </div>
185
+
186
+ {enabledDespiteGlobal.length > 0 && (
187
+ <Callout tone="warning">
188
+ Disabled globally, but still <strong>sold</strong> in{' '}
189
+ {joinLabels(enabledDespiteGlobal)} because of a channel override.
190
+ </Callout>
107
191
  )}
108
- <ul className="divide-y rounded-md border">
109
- {rows.map(row => {
110
- const canUpdate = canUpdateIn(row.channelId);
111
- return (
112
- <li key={row.channelId} className="space-y-2 p-2">
113
- <div className="flex items-center justify-between gap-2">
114
- <span className="truncate text-sm font-medium" title={row.channelCode}>
115
- {row.channelCode}
116
- </span>
117
- <Badge variant={row.effectiveEnabled ? 'success' : 'secondary'}>
118
- {row.effectiveEnabled ? 'Available' : 'Unavailable'}
119
- </Badge>
120
- </div>
121
- <div className="text-xs text-muted-foreground">
122
- {row.overrideState === 'INHERITED'
123
- ? 'Inherited from global status'
124
- : row.overrideState === 'ENABLED'
125
- ? 'Explicitly enabled in this channel'
126
- : 'Explicitly disabled in this channel'}
127
- </div>
128
- {canUpdate ? (
129
- <div className="flex flex-wrap gap-1">
130
- {row.overrideState !== 'ENABLED' && (
131
- <Button
132
- size="sm"
133
- variant="outline"
134
- disabled={busy}
135
- onClick={() => setOverride.mutate({ channelId: row.channelId, enabled: true })}
136
- >
137
- Enable
138
- </Button>
139
- )}
140
- {row.overrideState !== 'DISABLED' && (
141
- <Button
142
- size="sm"
143
- variant="outline"
144
- disabled={busy}
145
- onClick={() => setOverride.mutate({ channelId: row.channelId, enabled: false })}
146
- >
147
- Disable
148
- </Button>
149
- )}
150
- {row.overrideState !== 'INHERITED' && (
151
- <Button
152
- size="sm"
153
- variant="ghost"
154
- disabled={busy}
155
- onClick={() => clearOverride.mutate({ channelId: row.channelId })}
156
- >
157
- Use global
158
- </Button>
159
- )}
160
- </div>
161
- ) : (
162
- <div className="text-xs text-muted-foreground">Read only</div>
163
- )}
164
- </li>
165
- );
166
- })}
192
+ {hiddenDespiteGlobal.length > 0 && (
193
+ <Callout tone="info">
194
+ Enabled globally, but <strong>hidden</strong> in {joinLabels(hiddenDespiteGlobal)}.
195
+ </Callout>
196
+ )}
197
+
198
+ <ul className="space-y-2">
199
+ {rows.map(row => (
200
+ <ChannelRow
201
+ key={row.channelId}
202
+ row={row}
203
+ isActive={row.channelId === activeChannel?.id}
204
+ canUpdate={canUpdateIn(row.channelId)}
205
+ pending={mutation.isPending && mutation.variables?.channelId === row.channelId}
206
+ disabled={mutation.isPending}
207
+ onChange={next =>
208
+ next !== row.overrideState &&
209
+ mutation.mutate({ channelId: row.channelId, channelCode: row.channelCode, next })
210
+ }
211
+ />
212
+ ))}
167
213
  </ul>
168
214
  </div>
169
215
  );
170
216
  }
217
+
218
+ function ChannelRow({
219
+ row,
220
+ isActive,
221
+ canUpdate,
222
+ pending,
223
+ disabled,
224
+ onChange,
225
+ }: {
226
+ row: Row;
227
+ isActive: boolean;
228
+ canUpdate: boolean;
229
+ pending: boolean;
230
+ disabled: boolean;
231
+ onChange: (next: OverrideState) => void;
232
+ }) {
233
+ const options: Array<{ value: OverrideState; label: string; hint: string }> = [
234
+ { value: 'INHERITED', label: 'Global', hint: `Follow the global status (${row.globalEnabled ? 'on' : 'off'})` },
235
+ { value: 'ENABLED', label: 'On', hint: 'Always available in this channel' },
236
+ { value: 'DISABLED', label: 'Off', hint: 'Always hidden in this channel' },
237
+ ];
238
+ const description =
239
+ row.overrideState === 'INHERITED'
240
+ ? `Follows global (${row.globalEnabled ? 'on' : 'off'})`
241
+ : row.overrideState === 'ENABLED'
242
+ ? row.globalEnabled
243
+ ? 'Always on here'
244
+ : 'On here, despite global off'
245
+ : 'Always off here';
246
+
247
+ return (
248
+ <li
249
+ className={cx(
250
+ 'rounded-lg border bg-card p-3 transition-colors',
251
+ 'border-l-[3px]',
252
+ row.effectiveEnabled ? 'border-l-success' : 'border-l-muted-foreground/30',
253
+ )}
254
+ >
255
+ <div className="flex items-center justify-between gap-2">
256
+ <p className="min-w-0 truncate text-sm font-medium" title={row.channelCode}>
257
+ <ChannelCodeLabel code={row.channelCode} />
258
+ </p>
259
+ <StatusPill available={row.effectiveEnabled} pending={pending} />
260
+ </div>
261
+ <p className="mt-0.5 text-xs text-muted-foreground">
262
+ {isActive && <span className="font-medium text-foreground/70">Current · </span>}
263
+ {description}
264
+ </p>
265
+
266
+ {canUpdate ? (
267
+ <div
268
+ role="radiogroup"
269
+ aria-label={`Availability in ${channelLabel(row.channelCode)}`}
270
+ className="mt-3 grid grid-cols-3 gap-0.5 rounded-md bg-muted p-0.5"
271
+ >
272
+ {options.map(option => {
273
+ const selected = row.overrideState === option.value;
274
+ return (
275
+ <button
276
+ key={option.value}
277
+ type="button"
278
+ role="radio"
279
+ aria-checked={selected}
280
+ title={option.hint}
281
+ disabled={disabled}
282
+ onClick={() => onChange(option.value)}
283
+ className={cx(
284
+ 'inline-flex items-center justify-center gap-1 rounded-[5px] px-2 py-1 text-xs font-medium transition-all',
285
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
286
+ 'disabled:cursor-not-allowed',
287
+ selected
288
+ ? 'bg-background text-foreground shadow-sm'
289
+ : 'text-muted-foreground hover:text-foreground',
290
+ disabled && !selected && 'opacity-60',
291
+ )}
292
+ >
293
+ {option.value === 'INHERITED' && <Globe className="h-3 w-3" />}
294
+ {option.value === 'ENABLED' && <Eye className="h-3 w-3" />}
295
+ {option.value === 'DISABLED' && <EyeOff className="h-3 w-3" />}
296
+ {option.label}
297
+ </button>
298
+ );
299
+ })}
300
+ </div>
301
+ ) : (
302
+ <p
303
+ className="mt-2 inline-flex items-center gap-1 text-xs text-muted-foreground"
304
+ title="You need UpdateCatalog or UpdateProduct permission in this channel to change it."
305
+ >
306
+ <Lock className="h-3 w-3" />
307
+ View only in this channel
308
+ </p>
309
+ )}
310
+ </li>
311
+ );
312
+ }
313
+
314
+ function StatusPill({ available, pending }: { available: boolean; pending: boolean }) {
315
+ return (
316
+ <span
317
+ className={cx(
318
+ 'inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium',
319
+ available ? 'bg-success/10 text-success' : 'bg-muted text-muted-foreground',
320
+ )}
321
+ >
322
+ {pending ? (
323
+ <Loader2 className="h-3 w-3 animate-spin" />
324
+ ) : (
325
+ <span className={cx('h-1.5 w-1.5 rounded-full', available ? 'bg-success' : 'bg-muted-foreground/60')} />
326
+ )}
327
+ {available ? 'Available' : 'Hidden'}
328
+ </span>
329
+ );
330
+ }
331
+
332
+ function Callout({ tone, children }: { tone: 'warning' | 'info'; children: ReactNode }) {
333
+ return (
334
+ <div
335
+ className={cx(
336
+ 'flex gap-2 rounded-lg border p-2.5 text-xs leading-relaxed',
337
+ tone === 'warning' ? 'border-warning/40 bg-warning/10 text-warning' : 'bg-muted/50 text-muted-foreground',
338
+ )}
339
+ >
340
+ {tone === 'warning' ? (
341
+ <AlertTriangle className="mt-px h-3.5 w-3.5 shrink-0" />
342
+ ) : (
343
+ <Info className="mt-px h-3.5 w-3.5 shrink-0" />
344
+ )}
345
+ <span>{children}</span>
346
+ </div>
347
+ );
348
+ }
349
+
350
+ function channelLabel(code: string) {
351
+ return code === '__default_channel__' ? 'Default channel' : code;
352
+ }
353
+
354
+ function joinLabels(rows: Row[]) {
355
+ return rows.map(r => channelLabel(r.channelCode)).join(', ');
356
+ }
@@ -15,7 +15,13 @@ defineDashboardExtension({
15
15
  },
16
16
  requiresPermission: ['ReadCatalog', 'ReadProduct'],
17
17
  shouldRender: context => !!context.entity?.id,
18
- component: ({ context }) => <ChannelAvailabilityBlock productId={context.entity.id} />,
18
+ component: ({ context }) => (
19
+ <ChannelAvailabilityBlock
20
+ productId={context.entity.id}
21
+ productName={context.entity.name}
22
+ globalEnabledHint={context.entity.enabled}
23
+ />
24
+ ),
19
25
  },
20
26
  ],
21
27
  });