@aglyn/plugins-marketplace 1.19.0 → 1.20.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-marketplace",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,20 +25,20 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.19.0",
29
- "@aglyn/aglyn-markdown-editor": "1.19.0",
30
- "@aglyn/shared-data-enums": "1.19.0",
31
- "@aglyn/shared-data-mdi": "1.19.0",
32
- "@aglyn/shared-ui-jsx": "1.19.0",
33
- "@aglyn/shared-ui-next": "1.19.0",
34
- "@aglyn/shared-ui-snackstack": "1.19.0",
35
- "@aglyn/shared-util-email": "1.19.0",
36
- "@aglyn/shared-util-http": "1.19.0",
37
- "@aglyn/shared-util-timestamp": "1.19.0",
38
- "@aglyn/shared-util-tools": "1.19.0",
39
- "@aglyn/tenant-data-admin": "1.19.0",
40
- "@aglyn/tenant-feature-instance": "1.19.0",
41
- "@aglyn/tenant-runtime": "1.19.0",
28
+ "@aglyn/aglyn": "1.20.0",
29
+ "@aglyn/aglyn-markdown-editor": "1.20.0",
30
+ "@aglyn/shared-data-enums": "1.20.0",
31
+ "@aglyn/shared-data-mdi": "1.20.0",
32
+ "@aglyn/shared-ui-jsx": "1.20.0",
33
+ "@aglyn/shared-ui-next": "1.20.0",
34
+ "@aglyn/shared-ui-snackstack": "1.20.0",
35
+ "@aglyn/shared-util-email": "1.20.0",
36
+ "@aglyn/shared-util-http": "1.20.0",
37
+ "@aglyn/shared-util-timestamp": "1.20.0",
38
+ "@aglyn/shared-util-tools": "1.20.0",
39
+ "@aglyn/tenant-data-admin": "1.20.0",
40
+ "@aglyn/tenant-feature-instance": "1.20.0",
41
+ "@aglyn/tenant-runtime": "1.20.0",
42
42
  "@swc/helpers": "0.5.23"
43
43
  },
44
44
  "peerDependencies": {
@@ -1,3 +1,4 @@
1
+ import { type OrgInstallHost } from '../model/marketplace';
1
2
  export declare function ListingReadme({ readme }: {
2
3
  readme: string;
3
4
  }): import("react").JSX.Element;
@@ -41,12 +42,11 @@ export interface MarketplaceListingContentProps {
41
42
  /**
42
43
  * The org's sites, for the install-targeting picker (AGL-773). Present only
43
44
  * at org scope; when given (and non-empty) the CTA offers All sites vs
44
- * Selected sites instead of the single org/host choice.
45
+ * Selected sites instead of the single org/host choice. Each carries the
46
+ * site's own `disabledPlugins` when the shell has it (AGL-4047), which the
47
+ * installed-sites panel reads.
45
48
  */
46
- hosts?: ReadonlyArray<{
47
- id: string;
48
- label: string;
49
- }>;
49
+ hosts?: ReadonlyArray<OrgInstallHost>;
50
50
  }
51
51
  /**
52
52
  * Marketplace listing detail (AGL-95/419), relocated from the app route —
@@ -16,10 +16,12 @@
16
16
  */ 'use client';
17
17
  import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
19
- import { LISTING_README_MAX_CHARS, listingArtifactType, installsUnreviewedFallback, listingArtifactLabel, listingInclusions, hasLivePurchaseOf, hasOrgLicenceOf, licensedOrgIdsFor, installTargetsFor, isListingDeleted, isPrivateListing, resolveInstallPlan, resolveOrgInstallSummary, resolvePluginInstallState, resolveUninstallTargets, safePublisherHref, safePublisherImageSrc } from "../model/marketplace.js";
19
+ import { LISTING_README_MAX_CHARS, listingArtifactType, installsUnreviewedFallback, listingArtifactLabel, listingInclusions, hasLivePurchaseOf, hasOrgLicenceOf, licensedOrgIdsFor, installTargetsFor, isListingDeleted, isPrivateListing, resolveInstallPlan, resolveOrgInstallSummary, resolvePluginInstallState, resolveUninstallTargets, safePublisherHref, safePublisherImageSrc, allExceptInstallKind } from "../model/marketplace.js";
20
20
  import { isPublisherMemberElsewhere, landsInPublisherWorkspace, publisherMemberBuyReason } from "../model/own-listing.js";
21
21
  import { BlockedControl } from "@aglyn/shared-ui-jsx/components/blocked-control.component";
22
22
  import { PrerequisiteNotice } from "@aglyn/shared-ui-jsx/components/prerequisite-notice.component";
23
+ import { SiteSharingField } from "@aglyn/shared-ui-jsx/components/site-sharing-field.component";
24
+ import { orgPermissionLabel } from "@aglyn/aglyn/app-utils/org-permissions";
23
25
  import UninstallImpactDialog from "./uninstall-impact-dialog.component.js";
24
26
  import { ListingImage } from "./listing-image.component.js";
25
27
  import { buildRoute, parseMarkdownLite, PLATFORM_BRAND_NAME, offeredPluginVersion, PLUGIN_HOST_ABI_VERSION, pluginDocsHelp, resolveMarkdownLink, resolveMediaSrc, Route } from "@aglyn/aglyn";
@@ -27,7 +29,7 @@ import { mdiCheckCircle, mdiCheckCircleOutline, mdiEmailOutline, mdiGithub, mdiI
27
29
  import { AppLink, CardDisplay, GridItems, MdiIcon } from "@aglyn/shared-ui-jsx";
28
30
  import { NextPageTitle } from "@aglyn/shared-ui-next/contexts/next-page-title-provider";
29
31
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
30
- import { Alert, Avatar, Box, Button, Checkbox, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControl, FormControlLabel, FormGroup, FormHelperText, FormLabel, IconButton, Link as MuiLink, Rating, Stack, Tooltip, Typography } from "@mui/material";
32
+ import { Alert, Avatar, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider, IconButton, Link as MuiLink, Rating, Stack, Tooltip, Typography } from "@mui/material";
31
33
  import { collection, doc, documentId, getDoc, limit, orderBy, query, where } from "firebase/firestore";
32
34
  import { useEffect, useMemo, useState } from "react";
33
35
  import { useConsoleHostRoute, useFirestore, useFirestoreCollection, useFirestoreDoc, useHostOrgId, useUser, useScopeTokens } from "@aglyn/tenant-feature-instance";
@@ -427,15 +429,23 @@ export function ListingReadme({ readme }) {
427
429
  const [updateOpen, setUpdateOpen] = useState(false);
428
430
  // Screenshot lightbox (AGL-869): the clicked screenshot's URL, or null.
429
431
  const [lightboxUrl, setLightboxUrl] = useState(null);
430
- // Org-scope install targeting (AGL-773): All sites vs a chosen subset. Only
431
- // engaged at org scope with a known site list; otherwise the per-site tab's
432
- // simpler org/host choice (AGL-656) still applies.
432
+ // Org-scope install targeting (AGL-773): All sites, a chosen subset, or all
433
+ // sites except some (AGL-4047). Only engaged at org scope with a known site
434
+ // list; otherwise the per-site tab's simpler org/host choice (AGL-656) still
435
+ // applies.
433
436
  const orgTargeting = Boolean(orgScoped && hosts && hosts.length > 0);
434
- const [targeting, setTargeting] = useState('all-sites');
435
- const [selectedHostIds, setSelectedHostIds] = useState([]);
437
+ const [installTo, setInstallTo] = useState({
438
+ mode: 'all'
439
+ });
436
440
  const allHostIds = useMemo(()=>(hosts != null ? hosts : []).map((h)=>h.id), [
437
441
  hosts
438
442
  ]);
443
+ const installSites = useMemo(()=>(hosts != null ? hosts : []).map((host)=>({
444
+ id: host.id,
445
+ name: host.label
446
+ })), [
447
+ hosts
448
+ ]);
439
449
  // Listings are org-owned (AGL-652) — "did I publish this" is an org
440
450
  // comparison, resolved from the routing mirror like the browse grid.
441
451
  const [viewerOrgId, setViewerOrgId] = useState(null);
@@ -922,15 +932,49 @@ export function ListingReadme({ readme }) {
922
932
  // artifact can host-pin (plugins, components, templates, layouts) — an
923
933
  // org-only artifact can't target a subset.
924
934
  const canSelectSites = installTargets.includes('host');
935
+ // "All sites except…" (AGL-4047): a plugin pins once for the workspace and
936
+ // leaves the chosen sites out, so a site added later gets it; a per-site copy
937
+ // only picks today's sites; an org-only artifact can't leave one out.
938
+ const allExceptKind = listing ? allExceptInstallKind(listing) : null;
939
+ const installModes = useMemo(()=>[
940
+ 'all',
941
+ ...canSelectSites ? [
942
+ 'selected'
943
+ ] : [],
944
+ ...allExceptKind ? [
945
+ 'allExcept'
946
+ ] : []
947
+ ], [
948
+ canSelectSites,
949
+ allExceptKind
950
+ ]);
951
+ // A choice the artifact doesn't offer reads as All sites, never as a plan
952
+ // that silently means something else.
953
+ const installChoice = useMemo(()=>installModes.includes(installTo.mode) ? installTo : {
954
+ mode: 'all'
955
+ }, [
956
+ installModes,
957
+ installTo
958
+ ]);
959
+ const targeting = installChoice.mode === 'selected' ? 'selected-sites' : installChoice.mode === 'allExcept' ? 'all-sites-except' : 'all-sites';
925
960
  const installPlanSteps = useMemo(()=>listing ? resolveInstallPlan(listing, targeting, {
926
- selectedHostIds,
927
- allHostIds
961
+ selectedHostIds: installChoice.mode === 'selected' ? installChoice.hostIds : [],
962
+ allHostIds,
963
+ exceptHostIds: installChoice.mode === 'allExcept' ? installChoice.exceptHostIds : []
928
964
  }) : [], [
929
965
  listing,
930
966
  targeting,
931
- selectedHostIds,
967
+ installChoice,
932
968
  allHostIds
933
969
  ]);
970
+ const installModeDetails = installTargets.includes('org') ? {
971
+ all: 'Every site, including sites you add later.',
972
+ allExcept: 'Every site but the ones you leave out, including sites you add later.'
973
+ } : {
974
+ all: `Installs to all ${allHostIds.length} site` + (allHostIds.length === 1 ? '' : 's') + ". New sites won't get it automatically.",
975
+ allExcept: 'Installs to the sites you have today, but the ones you leave ' + "out. New sites won't get it automatically."
976
+ };
977
+ const installToProblem = installChoice.mode === 'selected' && !installChoice.hostIds.length ? 'Pick at least one site.' : installChoice.mode === 'allExcept' && !installChoice.exceptHostIds.length ? 'Choose at least one site to leave out, or choose All sites.' : null;
934
978
  const versionHistory = Array.isArray(listing == null ? void 0 : listing.versionHistory) ? [
935
979
  ...listing.versionHistory
936
980
  ].sort((a, b)=>b.version - a.version) : [];
@@ -940,8 +984,18 @@ export function ListingReadme({ readme }) {
940
984
  // something else.
941
985
  const installTargetSummary = ()=>{
942
986
  if (orgTargeting && !installed) {
943
- var _installPlanSteps_;
944
- if (installPlanSteps.length === 1 && ((_installPlanSteps_ = installPlanSteps[0]) == null ? void 0 : _installPlanSteps_.scope) === 'org') {
987
+ var _installPlanSteps_, _orgStep_exceptHostIds;
988
+ const orgStep = installPlanSteps.length === 1 && ((_installPlanSteps_ = installPlanSteps[0]) == null ? void 0 : _installPlanSteps_.scope) === 'org' ? installPlanSteps[0] : null;
989
+ if (orgStep == null ? void 0 : (_orgStep_exceptHostIds = orgStep.exceptHostIds) == null ? void 0 : _orgStep_exceptHostIds.length) {
990
+ // "All sites except…" (AGL-4047): the sites left out, by name.
991
+ const leftOut = orgStep.exceptHostIds.map((hostId)=>{
992
+ var _ref;
993
+ var _find;
994
+ return (_ref = (_find = (hosts != null ? hosts : []).find((host)=>host.id === hostId)) == null ? void 0 : _find.label) != null ? _ref : hostId;
995
+ });
996
+ return `every site except ${leftOut.join(', ')}, ` + 'including sites added later';
997
+ }
998
+ if (orgStep) {
945
999
  return 'the whole organization — every site, including sites added later';
946
1000
  }
947
1001
  const names = installPlanSteps.filter((step)=>step.scope === 'host').map((step)=>{
@@ -965,10 +1019,13 @@ export function ListingReadme({ readme }) {
965
1019
  // and which are still free to add. Only meaningful at org scope for a
966
1020
  // plugin — everything else installs INTO a site and has no pin to survey.
967
1021
  const orgPluginScope = Boolean(orgTargeting && isPlugin);
968
- const orgInstall = useMemo(()=>resolveOrgInstallSummary(hosts != null ? hosts : [], sitePins, orgPin != null ? orgPin : null), [
1022
+ const orgInstall = useMemo(// With each site's own switch (AGL-4047), so a site that turned the plugin
1023
+ // off, or was left out at install, is not listed as running it.
1024
+ ()=>resolveOrgInstallSummary(hosts != null ? hosts : [], sitePins, orgPin != null ? orgPin : null, listingId), [
969
1025
  hosts,
970
1026
  sitePins,
971
- orgPin
1027
+ orgPin,
1028
+ listingId
972
1029
  ]);
973
1030
  /** Run a pin write, then re-read every site's pin. */ const runSiteEdit = async (work)=>{
974
1031
  try {
@@ -1428,68 +1485,28 @@ export function ListingReadme({ readme }) {
1428
1485
  })
1429
1486
  }) : null,
1430
1487
  orgTargeting && !installed && !(orgPluginScope && orgInstall.installedAnywhere) ? /*#__PURE__*/ _jsxs(Stack, {
1431
- spacing: 1.5,
1488
+ spacing: 1,
1432
1489
  sx: {
1433
1490
  maxWidth: 420
1434
1491
  },
1435
1492
  children: [
1436
- /*#__PURE__*/ _jsxs(TextField, {
1437
- select: true,
1438
- size: "small",
1493
+ /*#__PURE__*/ _jsx(SiteSharingField, {
1439
1494
  label: "Install to",
1440
- value: targeting,
1441
- onChange: (event)=>setTargeting(event.target.value),
1442
- helperText: targeting === 'all-sites' ? installTargets.includes('org') ? 'Available on every site — including sites you add later.' : `Installs to all ${allHostIds.length} site` + (allHostIds.length === 1 ? '' : 's') + ". New sites won't get it automatically." : 'Installs only to the sites you choose.',
1443
- children: [
1444
- /*#__PURE__*/ _jsx(MenuItem, {
1445
- value: "all-sites",
1446
- children: 'All sites'
1447
- }),
1448
- canSelectSites ? /*#__PURE__*/ _jsx(MenuItem, {
1449
- value: "selected-sites",
1450
- children: 'Selected sites'
1451
- }) : null
1452
- ]
1495
+ value: installChoice,
1496
+ onChange: setInstallTo,
1497
+ sites: installSites,
1498
+ sitesReady: true,
1499
+ modes: installModes,
1500
+ modeDetails: installModeDetails,
1501
+ listLabels: {
1502
+ selected: 'Sites to install to'
1503
+ },
1504
+ problem: installToProblem
1453
1505
  }),
1454
- targeting === 'selected-sites' && canSelectSites ? /*#__PURE__*/ _jsxs(FormControl, {
1455
- component: "fieldset",
1456
- error: !selectedHostIds.length,
1457
- children: [
1458
- /*#__PURE__*/ _jsx(FormLabel, {
1459
- component: "legend",
1460
- sx: {
1461
- typography: 'caption'
1462
- },
1463
- children: 'Sites'
1464
- }),
1465
- /*#__PURE__*/ _jsx(FormGroup, {
1466
- // Scrolls rather than pushing Install off
1467
- // the page on an org with many sites.
1468
- sx: {
1469
- maxHeight: 240,
1470
- overflowY: 'auto'
1471
- },
1472
- children: (hosts != null ? hosts : []).map((host)=>/*#__PURE__*/ _jsx(FormControlLabel, {
1473
- control: /*#__PURE__*/ _jsx(Checkbox, {
1474
- size: "small",
1475
- checked: selectedHostIds.includes(host.id),
1476
- onChange: (event)=>setSelectedHostIds((current)=>event.target.checked ? [
1477
- ...current,
1478
- host.id
1479
- ] : current.filter((id)=>id !== host.id))
1480
- }),
1481
- label: host.label,
1482
- slotProps: {
1483
- typography: {
1484
- variant: 'body2'
1485
- }
1486
- }
1487
- }, host.id))
1488
- }),
1489
- /*#__PURE__*/ _jsx(FormHelperText, {
1490
- children: selectedHostIds.length ? `${selectedHostIds.length} of ${(hosts != null ? hosts : []).length} selected.` : 'Pick at least one site.'
1491
- })
1492
- ]
1506
+ installChoice.mode === 'allExcept' && allExceptKind === 'lasting' ? /*#__PURE__*/ _jsx(Typography, {
1507
+ variant: "caption",
1508
+ color: "text.secondary",
1509
+ children: 'Each site you leave out has the plugin ' + 'switched off, with the same switch as its ' + 'own Admin › Plugins page. Leaving a site ' + 'out takes ' + `“${orgPermissionLabel('site.manage')}” ` + 'there, and anyone holding it, which the ' + 'site’s admins do by default, can turn the ' + 'plugin back on for that site.'
1493
1510
  }) : null
1494
1511
  ]
1495
1512
  }) : installTargets.length > 1 && !installed ? /*#__PURE__*/ _jsxs(TextField, {