@igstack/app-catalog-frontend-core 6.3.1-alpha-20261003051755 → 6.5.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/dist/esm/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/utils/accessTabLabel.test.d.ts +1 -0
- package/dist/esm/api/infra/trpc.d.ts +42 -21
- package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +1 -0
- package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +19 -0
- package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +17 -2
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.d.ts +14 -0
- package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.js +115 -0
- package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js +1 -0
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +11 -4
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js +0 -4
- package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +4 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +377 -385
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDoor.d.ts +20 -0
- package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js +59 -0
- package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.d.ts +64 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +90 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.d.ts +13 -0
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js +433 -0
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +18 -1
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
- package/dist/esm/modules/appCatalog/utils/accessTabLabel.d.ts +30 -0
- package/dist/esm/modules/appCatalog/utils/accessTabLabel.js +34 -0
- package/dist/esm/modules/appCatalog/utils/accessTabLabel.js.map +1 -0
- package/dist/index.css +269 -5
- package/package.json +4 -4
- package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +17 -3
- package/src/__tests__/modules/appCatalog/ui/catalog/RequestAppDialog.test.tsx +113 -0
- package/src/__tests__/modules/appCatalog/ui/components/AccessRequestSection.test.tsx +6 -4
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +25 -10
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +193 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +2 -2
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +32 -9
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +125 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +2 -2
- package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.tsx +181 -0
- package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.tsx +131 -0
- package/src/__tests__/modules/appCatalog/utils/accessTabLabel.test.ts +206 -0
- package/src/__tests__/ui/components/header/Header.loginHidden.test.tsx +1 -1
- package/src/index.css +269 -5
- package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +24 -0
- package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +46 -2
- package/src/modules/appCatalog/ui/catalog/RequestAppDialog.tsx +169 -0
- package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +3 -0
- package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +9 -4
- package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +4 -1
- package/src/modules/appCatalog/ui/detail/AppDetails.tsx +574 -554
- package/src/modules/appCatalog/ui/detail/AppDoor.tsx +97 -0
- package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +195 -0
- package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +605 -0
- package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +20 -1
- package/src/modules/appCatalog/utils/accessTabLabel.ts +95 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.d.ts +0 -3
- package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js +0 -228
- package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js.map +0 -1
- package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.emptyState.test.tsx +0 -64
- package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.reviewStatus.test.tsx +0 -93
- package/src/modules/appCatalog/ui/detail/CommentsSection.tsx +0 -296
- /package/dist/esm/__tests__/modules/appCatalog/ui/{detail/CommentsSection.emptyState.test.d.ts → catalog/RequestAppDialog.test.d.ts} +0 -0
- /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{CommentsSection.reviewStatus.test.d.ts → AppDetails.doorAndTabs.test.d.ts} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"accessTabLabel.js","sources":["../../../../../src/modules/appCatalog/utils/accessTabLabel.ts"],"sourcesContent":["import type {\n ApprovalMethod,\n Resource,\n} from '@igstack/app-catalog-backend-core'\n\n/**\n * What the detail card's access tab says about itself.\n *\n * `route` names how you get in — the tab reads `Access — <route>` — and `chip`\n * is the one number worth carrying next to it.\n */\nexport interface AccessTabLabel {\n route: string\n chip?: string\n}\n\n/** Shown when nothing in the entry records a way in. */\nexport const ROUTE_UNDOCUMENTED = 'not documented'\n/** Shown when the resource needs no request at all. */\nexport const ROUTE_FREE = 'Free'\n/** A request with no named channel behind it. */\nexport const ROUTE_REQUEST = 'request'\n\n/**\n * Names the access route for a resource's tab.\n *\n * The point is that the tab answers \"can I get in?\" BEFORE it is opened. That\n * is what lets every entry carry the same four tabs: an access tab holding one\n * sentence saying the route is unknown is a dead end when you have to click to\n * find out, and merely a fact you already read when the label says it.\n *\n * A named channel beats the step count, because the channel is the thing people\n * recognise — most entries route through one or two of them, and \"two steps\"\n * describes effort rather than destination. The count rides in `chip` instead.\n */\nexport function accessTabLabel(\n resource: Resource,\n approvalMethods: ApprovalMethod[],\n): AccessTabLabel {\n const request = resource.accessRequest\n // Children carry their access as top-level fields rather than an\n // `accessRequest`, and the detail card's access section already falls back to\n // them. The tab has to agree with the section below it, or the label promises\n // something the panel does not show.\n const hasContactsOnly =\n (resource.approverSlugs?.length ?? 0) > 0 ||\n Boolean(resource.accessComments)\n\n if (!request) {\n return hasContactsOnly\n ? { route: ROUTE_REQUEST, chip: undefined }\n : { route: ROUTE_UNDOCUMENTED }\n }\n\n const method = approvalMethods.find(\n (m) => m.slug === request.approvalMethodSlug,\n )\n const hasWrittenSteps = Boolean(request.requestPrompt || request.comments)\n const isTwoStep = Boolean(hasWrittenSteps && request.postApprovalInstructions)\n\n // Steps before roles: two-step is the rarer and more consequential shape, and\n // an entry that is both (a named channel AND a follow-up) is exactly the one\n // where \"there is more to do after approval\" is the thing worth knowing.\n const roleCount = request.roles?.length ?? 0\n const chip = isTwoStep\n ? '2 steps'\n : roleCount > 0\n ? `${roleCount} role${roleCount === 1 ? '' : 's'}`\n : undefined\n\n if (method?.type === 'noAccessRequired') return { route: ROUTE_FREE, chip }\n\n // Nothing here can name a channel: `custom` has none by definition, and an\n // `approvalMethodSlug` that resolves to no method is a dangling reference —\n // the section below renders an access box with nothing in it either way.\n const cannotNameRoute = !method || method.type === 'custom'\n\n // `unknown` is the honest \"we did not find out\". A route we cannot name AND\n // cannot describe is the same dead end wearing a different slug: claiming\n // \"request\" there would promise a way in that the panel does not show.\n if (method?.type === 'unknown' || (cannotNameRoute && !hasWrittenSteps)) {\n return { route: ROUTE_UNDOCUMENTED, chip }\n }\n\n // A named desk, bot or team. `shortName` exists because `displayName` is\n // written for prose and the longest in use does not fit a tab. No `&& method`\n // guard: `cannotNameRoute` is `!method || …`, so ruling it out already narrows\n // `method` to present.\n if (!cannotNameRoute) {\n return { route: method.shortName ?? method.displayName, chip }\n }\n\n // Written steps with no channel to name them.\n return { route: isTwoStep ? '2 steps' : ROUTE_REQUEST, chip }\n}\n"],"names":[],"mappings":"AAiBO,MAAM,qBAAqB;AAE3B,MAAM,aAAa;AAEnB,MAAM,gBAAgB;AActB,SAAS,eACd,UACA,iBACgB;AArBX;AAsBL,QAAM,UAAU,SAAS;AAKzB,QAAM,qBACH,cAAS,kBAAT,mBAAwB,WAAU,KAAK,KACxC,QAAQ,SAAS,cAAc;AAEjC,MAAI,CAAC,SAAS;AACZ,WAAO,kBACH,EAAE,OAAO,eAAe,MAAM,OAAA,IAC9B,EAAE,OAAO,mBAAA;AAAA,EACf;AAEA,QAAM,SAAS,gBAAgB;AAAA,IAC7B,CAAC,MAAM,EAAE,SAAS,QAAQ;AAAA,EAAA;AAE5B,QAAM,kBAAkB,QAAQ,QAAQ,iBAAiB,QAAQ,QAAQ;AACzE,QAAM,YAAY,QAAQ,mBAAmB,QAAQ,wBAAwB;AAK7E,QAAM,cAAY,aAAQ,UAAR,mBAAe,WAAU;AAC3C,QAAM,OAAO,YACT,YACA,YAAY,IACV,GAAG,SAAS,QAAQ,cAAc,IAAI,KAAK,GAAG,KAC9C;AAEN,OAAI,iCAAQ,UAAS,2BAA2B,EAAE,OAAO,YAAY,KAAA;AAKrE,QAAM,kBAAkB,CAAC,UAAU,OAAO,SAAS;AAKnD,OAAI,iCAAQ,UAAS,aAAc,mBAAmB,CAAC,iBAAkB;AACvE,WAAO,EAAE,OAAO,oBAAoB,KAAA;AAAA,EACtC;AAMA,MAAI,CAAC,iBAAiB;AACpB,WAAO,EAAE,OAAO,OAAO,aAAa,OAAO,aAAa,KAAA;AAAA,EAC1D;AAGA,SAAO,EAAE,OAAO,YAAY,YAAY,eAAe,KAAA;AACzD;"}
|
package/dist/index.css
CHANGED
|
@@ -249,9 +249,16 @@
|
|
|
249
249
|
padding-left: 1.25rem;
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
/*
|
|
253
|
-
|
|
254
|
-
|
|
252
|
+
/* Review labels. Plain CSS rather than utilities: there is no success token in the
|
|
253
|
+
palette, and a tinted chip needs a colour the theme does not name. `--chart-3` is
|
|
254
|
+
the palette's green and already shifts for dark mode.
|
|
255
|
+
|
|
256
|
+
Mix percentages are low on purpose. At 75%/72% these measured 3.55:1 and 3.24:1
|
|
257
|
+
against the cream page — under the 4.5:1 floor for 11px text — and the old 600/700
|
|
258
|
+
weights were compensating for that, which is why they shouted and were still hard
|
|
259
|
+
to read. Lowering the mix raises contrast in both themes, so there is no .dark
|
|
260
|
+
override. Verify any change by measurement, not by eye: 60%/55% looks fine and
|
|
261
|
+
still fails. */
|
|
255
262
|
.comment-chip {
|
|
256
263
|
display: inline-flex;
|
|
257
264
|
align-items: center;
|
|
@@ -259,12 +266,13 @@
|
|
|
259
266
|
border-radius: 999px;
|
|
260
267
|
padding: 0.05rem 0.5rem;
|
|
261
268
|
font-size: 0.6875rem;
|
|
262
|
-
font-weight:
|
|
269
|
+
font-weight: 500;
|
|
263
270
|
line-height: 1.5;
|
|
264
271
|
white-space: nowrap;
|
|
265
272
|
}
|
|
266
273
|
.comment-chip-applied {
|
|
267
|
-
|
|
274
|
+
/* 5.46:1 light, 7.67:1 dark */
|
|
275
|
+
color: color-mix(in oklab, var(--chart-3) 50%, var(--foreground));
|
|
268
276
|
background: color-mix(in oklab, var(--chart-3) 16%, var(--card));
|
|
269
277
|
border: 1px solid color-mix(in oklab, var(--chart-3) 35%, var(--card));
|
|
270
278
|
}
|
|
@@ -275,6 +283,262 @@
|
|
|
275
283
|
font-weight: 400;
|
|
276
284
|
}
|
|
277
285
|
|
|
286
|
+
/* One register for every section label inside the detail card.
|
|
287
|
+
Measured: the old heading was 13px/500 above 13.5px/400 body — a single
|
|
288
|
+
100-unit weight step as the ONLY distinction, with no size, tracking or case
|
|
289
|
+
change. It read as "too pale" while already measuring 14.45:1 against the
|
|
290
|
+
card, so darkening was never available; the register was the problem.
|
|
291
|
+
Four signals now instead of one. `--foreground` deliberately: muting drops it
|
|
292
|
+
to 5.46:1 on a label whose complaint was paleness. Margins carry the rest
|
|
293
|
+
(28px between sections, 6px to the label's own body) and there are no rules
|
|
294
|
+
per section — a tab panel's edge already states that boundary. */
|
|
295
|
+
.section-label {
|
|
296
|
+
font-size: 0.6875rem;
|
|
297
|
+
font-weight: 700;
|
|
298
|
+
letter-spacing: 0.06em;
|
|
299
|
+
text-transform: uppercase;
|
|
300
|
+
color: var(--foreground);
|
|
301
|
+
line-height: 1.4;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/* The way into the app: icon, name and address fused into ONE pressable
|
|
305
|
+
surface at the top of the detail card. Plain CSS, not utilities — every
|
|
306
|
+
colour here is a measured `color-mix`, and the arbitrary-value classes that
|
|
307
|
+
would be needed are exactly what the JIT can miss.
|
|
308
|
+
|
|
309
|
+
Padding is always present and cancelled by an equal negative margin, so the
|
|
310
|
+
surface arriving on hover never nudges the text inside it. Only colour,
|
|
311
|
+
border, shadow and a 2px lift animate. */
|
|
312
|
+
.app-door {
|
|
313
|
+
display: inline-flex;
|
|
314
|
+
align-items: center;
|
|
315
|
+
gap: 0.9375rem;
|
|
316
|
+
max-width: calc(100% + 2.125rem);
|
|
317
|
+
margin: -0.6875rem -1.0625rem;
|
|
318
|
+
padding: 0.6875rem 1.0625rem;
|
|
319
|
+
border: 1px solid var(--border);
|
|
320
|
+
border-radius: 0.875rem;
|
|
321
|
+
/* A whisper at rest, so the block reads as a surface — therefore pressable —
|
|
322
|
+
before anyone hovers it. Most first-time visitors scan rather than hover. */
|
|
323
|
+
background: color-mix(in oklab, var(--primary) 2.5%, var(--card));
|
|
324
|
+
text-decoration: none;
|
|
325
|
+
color: inherit;
|
|
326
|
+
cursor: pointer;
|
|
327
|
+
transition:
|
|
328
|
+
background-color 0.14s ease-out,
|
|
329
|
+
border-color 0.14s ease-out,
|
|
330
|
+
box-shadow 0.14s ease-out,
|
|
331
|
+
translate 0.14s ease-out;
|
|
332
|
+
}
|
|
333
|
+
.app-door:hover,
|
|
334
|
+
.app-door:focus-visible {
|
|
335
|
+
background: color-mix(in oklab, var(--primary) 7%, var(--card));
|
|
336
|
+
border-color: color-mix(in oklab, var(--primary) 38%, var(--border));
|
|
337
|
+
box-shadow: 0 5px 16px -7px
|
|
338
|
+
color-mix(in oklab, var(--primary) 45%, transparent);
|
|
339
|
+
translate: 0 -2px;
|
|
340
|
+
outline: none;
|
|
341
|
+
}
|
|
342
|
+
.app-door:focus-visible {
|
|
343
|
+
box-shadow: 0 0 0 3px color-mix(in oklab, var(--primary) 25%, transparent);
|
|
344
|
+
}
|
|
345
|
+
.app-door:active {
|
|
346
|
+
translate: 0 0;
|
|
347
|
+
}
|
|
348
|
+
/* The icon lifts WITH the surface, so the logo reads as part of the press
|
|
349
|
+
rather than something parked beside it. Shadow only, deliberately: the icon
|
|
350
|
+
is either an <img> (no background to tint) or a monogram whose colour is set
|
|
351
|
+
as an INLINE style, which a class cannot override — and recolouring a brand
|
|
352
|
+
logo on hover would be wrong even where it worked. */
|
|
353
|
+
.app-door__icon {
|
|
354
|
+
transition: box-shadow 0.14s ease-out;
|
|
355
|
+
}
|
|
356
|
+
.app-door:hover .app-door__icon,
|
|
357
|
+
.app-door:focus-visible .app-door__icon {
|
|
358
|
+
box-shadow: 0 2px 8px -3px
|
|
359
|
+
color-mix(in oklab, var(--primary) 55%, transparent);
|
|
360
|
+
}
|
|
361
|
+
/* The title must NOT turn brand-coloured on hover. Measured against the hover
|
|
362
|
+
tint: `--foreground` holds 13.35:1 light / 12.60:1 dark, where the darkened
|
|
363
|
+
brand colour manages only 5.17:1 / 3.65:1 — so tinting it DROPPED contrast on
|
|
364
|
+
interaction. Hover, active and focus have to increase contrast, never reduce
|
|
365
|
+
it. The surface, border, shadow, icon tile and glyph already carry the state
|
|
366
|
+
change; the brand tint was the only cue that worked by going paler. */
|
|
367
|
+
.app-door__title {
|
|
368
|
+
display: block;
|
|
369
|
+
font-family: var(--font-serif);
|
|
370
|
+
font-size: 1.5625rem;
|
|
371
|
+
font-weight: 600;
|
|
372
|
+
line-height: 1.2;
|
|
373
|
+
}
|
|
374
|
+
/* One size step past the point where the longest names run the surface past
|
|
375
|
+
the card. Only a handful of entries are this long, but they include some of
|
|
376
|
+
the most-opened ones. */
|
|
377
|
+
.app-door__title--long {
|
|
378
|
+
font-size: 1.3125rem;
|
|
379
|
+
}
|
|
380
|
+
.app-door__url {
|
|
381
|
+
display: block;
|
|
382
|
+
margin-top: 0.25rem;
|
|
383
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
384
|
+
font-size: 0.75rem;
|
|
385
|
+
line-height: 1.4;
|
|
386
|
+
color: var(--muted-foreground);
|
|
387
|
+
overflow: hidden;
|
|
388
|
+
text-overflow: ellipsis;
|
|
389
|
+
white-space: nowrap;
|
|
390
|
+
transition: color 0.14s ease-out;
|
|
391
|
+
}
|
|
392
|
+
.app-door:hover .app-door__url,
|
|
393
|
+
.app-door:focus-visible .app-door__url {
|
|
394
|
+
color: var(--foreground);
|
|
395
|
+
}
|
|
396
|
+
.app-door__glyph {
|
|
397
|
+
flex: none;
|
|
398
|
+
color: var(--muted-foreground);
|
|
399
|
+
opacity: 0.75;
|
|
400
|
+
transition:
|
|
401
|
+
opacity 0.14s ease-out,
|
|
402
|
+
color 0.14s ease-out,
|
|
403
|
+
translate 0.14s ease-out;
|
|
404
|
+
}
|
|
405
|
+
.app-door:hover .app-door__glyph,
|
|
406
|
+
.app-door:focus-visible .app-door__glyph {
|
|
407
|
+
opacity: 1;
|
|
408
|
+
color: color-mix(in oklab, var(--primary) 85%, black);
|
|
409
|
+
translate: 2px -2px;
|
|
410
|
+
}
|
|
411
|
+
/* No hover on a touch screen, so a hover-only affordance is invisible there.
|
|
412
|
+
Show the surface at full strength instead. */
|
|
413
|
+
@media (hover: none) {
|
|
414
|
+
.app-door {
|
|
415
|
+
background: color-mix(in oklab, var(--primary) 6%, var(--card));
|
|
416
|
+
border-color: color-mix(in oklab, var(--primary) 30%, var(--border));
|
|
417
|
+
}
|
|
418
|
+
.app-door__glyph {
|
|
419
|
+
opacity: 1;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/* The detail card's hero band: identity, the way in, and the typed-id jumps,
|
|
424
|
+
with the tab strip seated on its bottom edge so the two read as one unit and
|
|
425
|
+
the panel below reads as contents. The tint is faint on purpose — the access
|
|
426
|
+
section carries a stronger one, and two competing tinted panels in the first
|
|
427
|
+
screen was the objection that nearly sank this layout. */
|
|
428
|
+
.detail-band {
|
|
429
|
+
background: color-mix(in oklab, var(--primary) 4.5%, var(--card));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/* The tab strip. Horizontally scrollable rather than wrapping: a wrapped strip
|
|
433
|
+
changes the band's height from one entry to the next. */
|
|
434
|
+
.detail-tabs {
|
|
435
|
+
display: flex;
|
|
436
|
+
gap: 2px;
|
|
437
|
+
border-bottom: 1px solid var(--border);
|
|
438
|
+
overflow-x: auto;
|
|
439
|
+
scrollbar-width: none;
|
|
440
|
+
}
|
|
441
|
+
.detail-tabs::-webkit-scrollbar {
|
|
442
|
+
display: none;
|
|
443
|
+
}
|
|
444
|
+
.detail-tab {
|
|
445
|
+
position: relative;
|
|
446
|
+
display: inline-flex;
|
|
447
|
+
align-items: center;
|
|
448
|
+
gap: 0.4375rem;
|
|
449
|
+
padding: 0.625rem 0.875rem 0.6875rem;
|
|
450
|
+
border: 0;
|
|
451
|
+
border-radius: 0.5rem 0.5rem 0 0;
|
|
452
|
+
background: none;
|
|
453
|
+
font-size: 0.84375rem;
|
|
454
|
+
font-weight: 600;
|
|
455
|
+
white-space: nowrap;
|
|
456
|
+
color: var(--muted-foreground);
|
|
457
|
+
cursor: pointer;
|
|
458
|
+
transition:
|
|
459
|
+
color 0.14s ease-out,
|
|
460
|
+
background-color 0.14s ease-out;
|
|
461
|
+
}
|
|
462
|
+
.detail-tab:hover {
|
|
463
|
+
color: var(--foreground);
|
|
464
|
+
background: color-mix(in oklab, var(--primary) 5%, transparent);
|
|
465
|
+
}
|
|
466
|
+
/* The marker sits ON the strip's own border, so the active tab reads as
|
|
467
|
+
attached to the panel below rather than underlined. */
|
|
468
|
+
.detail-tab::after {
|
|
469
|
+
content: '';
|
|
470
|
+
position: absolute;
|
|
471
|
+
left: 0.625rem;
|
|
472
|
+
right: 0.625rem;
|
|
473
|
+
bottom: -1px;
|
|
474
|
+
height: 2.5px;
|
|
475
|
+
border-radius: 2px;
|
|
476
|
+
background: transparent;
|
|
477
|
+
transition: background-color 0.14s ease-out;
|
|
478
|
+
}
|
|
479
|
+
.detail-tab[aria-selected='true'] {
|
|
480
|
+
color: color-mix(in oklab, var(--primary) 85%, black);
|
|
481
|
+
}
|
|
482
|
+
.detail-tab[aria-selected='true']::after {
|
|
483
|
+
background: color-mix(in oklab, var(--primary) 85%, black);
|
|
484
|
+
}
|
|
485
|
+
.detail-tab:focus-visible {
|
|
486
|
+
outline: 2px solid color-mix(in oklab, var(--primary) 85%, black);
|
|
487
|
+
outline-offset: -2px;
|
|
488
|
+
}
|
|
489
|
+
/* Measured: `--muted-foreground` on `--secondary` at this size is 4.65:1 light
|
|
490
|
+
— over the 4.5:1 floor by 0.15, which is not a margin. Mixed toward
|
|
491
|
+
`--foreground` instead: 8.16:1 light, 8.24:1 dark. The active chip formerly
|
|
492
|
+
used the brand colour on a brand tint, which measured 4.46:1 light and
|
|
493
|
+
3.00:1 dark — failing — so the tint alone now marks "active" and the text
|
|
494
|
+
stays at full `--foreground`. Weight is for emphasis; it is not contrast. */
|
|
495
|
+
.detail-tab__chip {
|
|
496
|
+
border-radius: 999px;
|
|
497
|
+
padding: 2px 7px;
|
|
498
|
+
font-size: 0.65625rem;
|
|
499
|
+
font-weight: 700;
|
|
500
|
+
line-height: 1.3;
|
|
501
|
+
background: var(--secondary);
|
|
502
|
+
color: color-mix(in oklab, var(--foreground) 55%, var(--muted-foreground));
|
|
503
|
+
}
|
|
504
|
+
.detail-tab[aria-selected='true'] .detail-tab__chip {
|
|
505
|
+
background: color-mix(in oklab, var(--primary) 16%, transparent);
|
|
506
|
+
color: var(--foreground);
|
|
507
|
+
}
|
|
508
|
+
/* "Something here is unreviewed", visible from any tab. Decorative, so it is
|
|
509
|
+
exempt on text-contrast grounds — and never the only carrier: the tab's
|
|
510
|
+
accessible name says it too. */
|
|
511
|
+
.detail-tab__dot {
|
|
512
|
+
width: 7px;
|
|
513
|
+
height: 7px;
|
|
514
|
+
flex: none;
|
|
515
|
+
border-radius: 999px;
|
|
516
|
+
background: var(--chart-4, oklch(0.72 0.16 70));
|
|
517
|
+
box-shadow: 0 0 0 2px
|
|
518
|
+
color-mix(in oklab, var(--chart-4, oklch(0.72 0.16 70)) 25%, transparent);
|
|
519
|
+
|
|
520
|
+
/* How many items are still waiting on a maintainer. Amber rather than red: an entry
|
|
521
|
+
with open feedback is disputed, not broken, and red is already the withdraw
|
|
522
|
+
affordance in the same thread. 5.45:1 light, 8.08:1 dark. */
|
|
523
|
+
.feedback-open-count {
|
|
524
|
+
display: inline-flex;
|
|
525
|
+
align-items: center;
|
|
526
|
+
justify-content: center;
|
|
527
|
+
min-width: 1.15rem;
|
|
528
|
+
border-radius: 999px;
|
|
529
|
+
padding: 0.15rem 0.35rem;
|
|
530
|
+
font-size: 0.6875rem;
|
|
531
|
+
font-weight: 600;
|
|
532
|
+
line-height: 1;
|
|
533
|
+
letter-spacing: normal;
|
|
534
|
+
text-transform: none;
|
|
535
|
+
white-space: nowrap;
|
|
536
|
+
color: color-mix(in oklab, var(--chart-4) 45%, var(--foreground));
|
|
537
|
+
background: color-mix(in oklab, var(--chart-4) 18%, var(--card));
|
|
538
|
+
border: 1px solid color-mix(in oklab, var(--chart-4) 38%, var(--card));
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
278
542
|
/* tw-animate-css ships no reduced-motion rules of its own, so honour the
|
|
279
543
|
preference here — the overlay animations it provides are all decorative. */
|
|
280
544
|
@media (prefers-reduced-motion: reduce) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igstack/app-catalog-frontend-core",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.5.0",
|
|
4
4
|
"description": "Frontend core library for App Catalog",
|
|
5
5
|
"homepage": "https://github.com/lislon/app-catalog",
|
|
6
6
|
"repository": {
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
"tailwindcss": "^4.1.7",
|
|
113
113
|
"tiptap-markdown": "^0.8.0",
|
|
114
114
|
"tslib": "^2.3.0",
|
|
115
|
-
"@igstack/app-catalog-shared-core": "6.
|
|
115
|
+
"@igstack/app-catalog-shared-core": "6.5.0"
|
|
116
116
|
},
|
|
117
117
|
"devDependencies": {
|
|
118
118
|
"@tailwindcss/vite": "^4.0.5",
|
|
@@ -139,7 +139,7 @@
|
|
|
139
139
|
"vite-plugin-static-copy": "^3.1.4",
|
|
140
140
|
"vite-plugin-svgr": "^4.2.0",
|
|
141
141
|
"vitest": "^4.1.2",
|
|
142
|
-
"@igstack/app-catalog-backend-core": "6.
|
|
142
|
+
"@igstack/app-catalog-backend-core": "6.5.0"
|
|
143
143
|
},
|
|
144
144
|
"peerDependencies": {
|
|
145
145
|
"react": "19.1.2",
|
|
@@ -148,7 +148,7 @@
|
|
|
148
148
|
"vite": "^6.3.5",
|
|
149
149
|
"vite-plugin-svgr": "^4.2.0"
|
|
150
150
|
},
|
|
151
|
-
"gitHead": "
|
|
151
|
+
"gitHead": "fff8c62c5136b8b1fdb9631ee42c261ba7a35bc6",
|
|
152
152
|
"scripts": {
|
|
153
153
|
"build": "vite build",
|
|
154
154
|
"build:lenient": "vite build --mode lenient",
|
|
@@ -33,8 +33,8 @@ vi.mock('~/modules/appCatalog/ui/context/AppCatalogFiltersContext', () => ({
|
|
|
33
33
|
})),
|
|
34
34
|
}))
|
|
35
35
|
vi.mock('~/modules/auth', () => ({ useUser: vi.fn(() => null) }))
|
|
36
|
-
vi.mock('~/modules/appCatalog/ui/detail/
|
|
37
|
-
|
|
36
|
+
vi.mock('~/modules/appCatalog/ui/detail/FeedbackSection', () => ({
|
|
37
|
+
FeedbackSection: () => <div />,
|
|
38
38
|
}))
|
|
39
39
|
|
|
40
40
|
const { AppDetailPanel } =
|
|
@@ -62,6 +62,13 @@ function roleRowCount() {
|
|
|
62
62
|
?.querySelectorAll('tr').length
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// The roles table lives behind the access tab, so every assertion about it has
|
|
66
|
+
// to open that tab first. An app switch remounts the card body and resets the
|
|
67
|
+
// card to its first tab, so this is needed again on the far side of a switch.
|
|
68
|
+
function openAccessTab() {
|
|
69
|
+
fireEvent.click(screen.getByRole('tab', { name: /^access/i }))
|
|
70
|
+
}
|
|
71
|
+
|
|
65
72
|
// Two flows reach a different app without closing the card: "View replacement"
|
|
66
73
|
// on a deprecated entry, and an access prerequisite's parent. Both swap the
|
|
67
74
|
// `app` prop, and nothing in the chain down to the roles table was keyed by it,
|
|
@@ -74,6 +81,7 @@ describe('AppDetailPanel — navigating from one app to another', () => {
|
|
|
74
81
|
<AppDetailPanel app={appWithRoles('labvantage', 8)} onClose={vi.fn()} />,
|
|
75
82
|
)
|
|
76
83
|
|
|
84
|
+
openAccessTab()
|
|
77
85
|
fireEvent.click(
|
|
78
86
|
screen.getByRole('button', { name: /Show all \(8\) roles/ }),
|
|
79
87
|
)
|
|
@@ -84,6 +92,7 @@ describe('AppDetailPanel — navigating from one app to another', () => {
|
|
|
84
92
|
rerender(
|
|
85
93
|
<AppDetailPanel app={appWithRoles('alation', 7)} onClose={vi.fn()} />,
|
|
86
94
|
)
|
|
95
|
+
openAccessTab()
|
|
87
96
|
|
|
88
97
|
const toggle = screen.getByRole('button', { name: /Show all \(7\) roles/ })
|
|
89
98
|
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
|
@@ -99,11 +108,14 @@ describe('AppDetailPanel — navigating from one app to another', () => {
|
|
|
99
108
|
<AppDetailPanel app={appWithRoles('labvantage', 8)} onClose={vi.fn()} />,
|
|
100
109
|
)
|
|
101
110
|
|
|
111
|
+
openAccessTab()
|
|
102
112
|
fireEvent.click(
|
|
103
113
|
screen.getByRole('button', { name: /Show all \(8\) roles/ }),
|
|
104
114
|
)
|
|
105
115
|
// A fresh object for the same app, which is what a refetch hands down: an
|
|
106
116
|
// identity-based key would throw the reader's own expansion away here.
|
|
117
|
+
// No tab click on this side — the panel stays mounted through a same-app
|
|
118
|
+
// rerender, which is precisely what keeps the expansion.
|
|
107
119
|
rerender(
|
|
108
120
|
<AppDetailPanel app={appWithRoles('labvantage', 8)} onClose={vi.fn()} />,
|
|
109
121
|
)
|
|
@@ -144,7 +156,9 @@ describe('AppDetailPanel — focus after an app switch', () => {
|
|
|
144
156
|
)
|
|
145
157
|
// Stands in for the control that was activated to navigate ("View
|
|
146
158
|
// replacement", a prerequisite's parent): focused, and inside the body
|
|
147
|
-
// that is about to be replaced.
|
|
159
|
+
// that is about to be replaced. Deliberately a control inside a tab panel,
|
|
160
|
+
// which is where most of the card's focusable content now lives.
|
|
161
|
+
openAccessTab()
|
|
148
162
|
const toggle = screen.getByRole('button', {
|
|
149
163
|
name: /Show all \(8\) roles/,
|
|
150
164
|
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import '@testing-library/jest-dom/vitest'
|
|
5
|
+
|
|
6
|
+
const state = vi.hoisted(() => ({ sent: [] as unknown[] }))
|
|
7
|
+
|
|
8
|
+
vi.mock('@tanstack/react-query', () => ({
|
|
9
|
+
useMutation: vi.fn((options?: { onSuccess?: () => void }) => ({
|
|
10
|
+
mutate: (vars: unknown) => {
|
|
11
|
+
state.sent.push(vars)
|
|
12
|
+
options?.onSuccess?.()
|
|
13
|
+
},
|
|
14
|
+
isPending: false,
|
|
15
|
+
})),
|
|
16
|
+
useQueryClient: vi.fn(() => ({ invalidateQueries: vi.fn() })),
|
|
17
|
+
}))
|
|
18
|
+
vi.mock('~/api/infra/trpc', () => ({
|
|
19
|
+
useTRPC: vi.fn(() => ({
|
|
20
|
+
feedback: {
|
|
21
|
+
add: { mutationOptions: (o: unknown) => o },
|
|
22
|
+
mine: { queryKey: () => ['feedback', 'mine'] },
|
|
23
|
+
},
|
|
24
|
+
})),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
const { RequestAppDialog } =
|
|
28
|
+
await import('~/modules/appCatalog/ui/catalog/RequestAppDialog')
|
|
29
|
+
|
|
30
|
+
describe('RequestAppDialog', () => {
|
|
31
|
+
/**
|
|
32
|
+
* The grid is rendered bare in several tests with no TRPCProvider, so the closed
|
|
33
|
+
* dialog must not reach a tRPC hook. A hook cannot be called conditionally, which is
|
|
34
|
+
* why the gate is a component boundary — and why this test exists: inlining the body
|
|
35
|
+
* back into one component breaks six unrelated grid tests with
|
|
36
|
+
* "useTRPC() can only be used inside of a <TRPCProvider>".
|
|
37
|
+
*/
|
|
38
|
+
it('mounts nothing at all while closed', () => {
|
|
39
|
+
const { container } = render(
|
|
40
|
+
<RequestAppDialog open={false} onOpenChange={() => {}} />,
|
|
41
|
+
)
|
|
42
|
+
expect(container).toBeEmptyDOMElement()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('pre-fills what the visitor searched for', () => {
|
|
46
|
+
render(
|
|
47
|
+
<RequestAppDialog
|
|
48
|
+
open
|
|
49
|
+
onOpenChange={() => {}}
|
|
50
|
+
initialSubject="hoppy budget tool"
|
|
51
|
+
/>,
|
|
52
|
+
)
|
|
53
|
+
// They just typed it into the search box; asking again is a toll on the one
|
|
54
|
+
// person who already told us what is missing.
|
|
55
|
+
expect(screen.getByLabelText(/What are you looking for/)).toHaveValue(
|
|
56
|
+
'hoppy budget tool',
|
|
57
|
+
)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('refuses to send with no name, then sends a catalog-level ask', async () => {
|
|
61
|
+
state.sent = []
|
|
62
|
+
render(<RequestAppDialog open onOpenChange={() => {}} initialSubject="" />)
|
|
63
|
+
|
|
64
|
+
const send = screen.getByRole('button', { name: /Send request/ })
|
|
65
|
+
expect(send).toBeDisabled()
|
|
66
|
+
|
|
67
|
+
await userEvent.type(
|
|
68
|
+
screen.getByLabelText(/What are you looking for/),
|
|
69
|
+
'Grafana',
|
|
70
|
+
)
|
|
71
|
+
await userEvent.click(screen.getByRole('button', { name: /Send request/ }))
|
|
72
|
+
|
|
73
|
+
// No resourceSlug: that is what makes it a request for something absent rather
|
|
74
|
+
// than feedback on an existing entry. The server splits them the same way.
|
|
75
|
+
expect(state.sent).toEqual([{ subject: 'Grafana' }])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('carries the optional detail when given, and omits it when blank', async () => {
|
|
79
|
+
state.sent = []
|
|
80
|
+
render(
|
|
81
|
+
<RequestAppDialog
|
|
82
|
+
open
|
|
83
|
+
onOpenChange={() => {}}
|
|
84
|
+
initialSubject="Grafana"
|
|
85
|
+
/>,
|
|
86
|
+
)
|
|
87
|
+
await userEvent.type(
|
|
88
|
+
screen.getByLabelText(/Anything that would help/),
|
|
89
|
+
'the observability team runs it',
|
|
90
|
+
)
|
|
91
|
+
await userEvent.click(screen.getByRole('button', { name: /Send request/ }))
|
|
92
|
+
|
|
93
|
+
expect(state.sent).toEqual([
|
|
94
|
+
{ subject: 'Grafana', body: 'the observability team runs it' },
|
|
95
|
+
])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('confirms rather than leaving them wondering whether it sent', async () => {
|
|
99
|
+
state.sent = []
|
|
100
|
+
render(
|
|
101
|
+
<RequestAppDialog
|
|
102
|
+
open
|
|
103
|
+
onOpenChange={() => {}}
|
|
104
|
+
initialSubject="Grafana"
|
|
105
|
+
/>,
|
|
106
|
+
)
|
|
107
|
+
await userEvent.click(screen.getByRole('button', { name: /Send request/ }))
|
|
108
|
+
|
|
109
|
+
// Reporting into silence is how a person learns not to bother.
|
|
110
|
+
expect(screen.getByText(/that is logged/i)).toBeInTheDocument()
|
|
111
|
+
expect(screen.queryByRole('button', { name: /Send request/ })).toBeNull()
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -184,12 +184,14 @@ describe('AccessRequestSection — entries with no access information', () => {
|
|
|
184
184
|
displayName: 'Ghost',
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
it('still
|
|
187
|
+
it('still says something, instead of nothing, when every access field is empty', () => {
|
|
188
188
|
render(<AccessRequestSection app={bare} approvalMethods={[]} />)
|
|
189
189
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
190
|
+
// The frame and its "How to get access" heading were removed when access
|
|
191
|
+
// became its own tab — the tab's label states that, and a heading repeating
|
|
192
|
+
// the tab you just clicked is the same fact twice. What #181 actually
|
|
193
|
+
// guarantees is that the reader is TOLD we do not know, rather than shown an
|
|
194
|
+
// empty panel, so that is what this asserts.
|
|
193
195
|
expect(screen.getByText(/not documented yet/)).toBeInTheDocument()
|
|
194
196
|
})
|
|
195
197
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
-
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
3
3
|
import { describe, expect, it, vi } from 'vitest'
|
|
4
4
|
import '@testing-library/jest-dom/vitest'
|
|
5
5
|
|
|
@@ -28,8 +28,8 @@ vi.mock('~/modules/appCatalog/ui/context/AppCatalogFiltersContext', () => ({
|
|
|
28
28
|
useAppCatalogFilters: vi.fn(() => ({ selectedTags: [], toggleTag: vi.fn() })),
|
|
29
29
|
}))
|
|
30
30
|
vi.mock('~/modules/auth', () => ({ useUser: vi.fn(() => null) }))
|
|
31
|
-
vi.mock('~/modules/appCatalog/ui/detail/
|
|
32
|
-
|
|
31
|
+
vi.mock('~/modules/appCatalog/ui/detail/FeedbackSection', () => ({
|
|
32
|
+
FeedbackSection: () => <div />,
|
|
33
33
|
}))
|
|
34
34
|
|
|
35
35
|
const { AppDetails } = await import('~/modules/appCatalog/ui/detail/AppDetails')
|
|
@@ -42,28 +42,43 @@ const app = {
|
|
|
42
42
|
background: 'Built around 2015 for the clinic-facing side of one business.',
|
|
43
43
|
} as Resource
|
|
44
44
|
|
|
45
|
+
// The original contract was positional — history AFTER the description, never
|
|
46
|
+
// instead of it — and the card has since split those two across tabs, so no
|
|
47
|
+
// single DOM holds both. The intent survives unchanged: the description is what
|
|
48
|
+
// the card opens on, and the history is somewhere quieter that you choose to
|
|
49
|
+
// visit. That is what these assert now.
|
|
45
50
|
describe('AppDetails — Background', () => {
|
|
46
|
-
it('
|
|
51
|
+
it('opens on the description, not on the history', () => {
|
|
47
52
|
render(<AppDetails app={app} onClosePanel={vi.fn()} />)
|
|
48
53
|
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
expect(screen.getByRole('heading', { name: 'Description' })).toBeVisible()
|
|
55
|
+
expect(screen.queryByRole('heading', { name: 'Background' })).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('renders the history under Documentation', async () => {
|
|
59
|
+
render(<AppDetails app={app} onClosePanel={vi.fn()} />)
|
|
51
60
|
|
|
61
|
+
fireEvent.click(screen.getByRole('tab', { name: /documentation/i }))
|
|
62
|
+
|
|
63
|
+
expect(screen.getByRole('heading', { name: 'Background' })).toBeVisible()
|
|
52
64
|
expect(
|
|
53
65
|
screen.getByText(/Built around 2015 for the clinic-facing side/),
|
|
54
66
|
).toBeInTheDocument()
|
|
55
|
-
expect(
|
|
56
|
-
description.compareDocumentPosition(background) &
|
|
57
|
-
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
58
|
-
).toBeTruthy()
|
|
59
67
|
})
|
|
60
68
|
|
|
69
|
+
// Asserted ON the Documentation tab on purpose. Asserting it from Overview would
|
|
70
|
+
// pass whether or not the section works, because Background is in another
|
|
71
|
+
// panel either way — a test that cannot fail is worse than no test.
|
|
61
72
|
it('leaves no empty section when the entry has no history recorded', () => {
|
|
62
73
|
const { background: _omitted, ...withoutBackground } = app
|
|
63
74
|
render(
|
|
64
75
|
<AppDetails app={withoutBackground as Resource} onClosePanel={vi.fn()} />,
|
|
65
76
|
)
|
|
66
77
|
|
|
78
|
+
fireEvent.click(screen.getByRole('tab', { name: /documentation/i }))
|
|
79
|
+
|
|
67
80
|
expect(screen.queryByRole('heading', { name: 'Background' })).toBeNull()
|
|
81
|
+
// Proves the panel really is open, so the absence above means something.
|
|
82
|
+
expect(screen.getByRole('heading', { name: 'Sources' })).toBeVisible()
|
|
68
83
|
})
|
|
69
84
|
})
|