github.com/tilt-dev/tilt@v0.36.0/web/src/SidebarResources.tsx (about)

     1  import {
     2    Accordion,
     3    AccordionDetails,
     4    AccordionSummary,
     5  } from "@material-ui/core"
     6  import React, { ChangeEvent, useCallback, useState } from "react"
     7  import { Link } from "react-router-dom"
     8  import styled from "styled-components"
     9  import {
    10    DEFAULT_RESOURCE_LIST_LIMIT,
    11    RESOURCE_LIST_MULTIPLIER,
    12  } from "./constants"
    13  import { FeaturesContext, Flag, useFeatures } from "./feature"
    14  import {
    15    GroupByLabelView,
    16    orderLabels,
    17    TILTFILE_LABEL,
    18    UNLABELED_LABEL,
    19  } from "./labels"
    20  import { OverviewSidebarOptions } from "./OverviewSidebarOptions"
    21  import PathBuilder from "./PathBuilder"
    22  import {
    23    AccordionDetailsStyleResetMixin,
    24    AccordionStyleResetMixin,
    25    AccordionSummaryStyleResetMixin,
    26    ResourceGroupsInfoTip,
    27    ResourceGroupSummaryIcon,
    28    ResourceGroupSummaryMixin,
    29  } from "./ResourceGroups"
    30  import { useResourceGroups } from "./ResourceGroupsContext"
    31  import { ResourceListOptions } from "./ResourceListOptionsContext"
    32  import { matchesResourceName } from "./ResourceNameFilter"
    33  import { SidebarGroupStatusSummary } from "./ResourceStatusSummary"
    34  import { ShowMoreButton } from "./ShowMoreButton"
    35  import SidebarItem from "./SidebarItem"
    36  import SidebarItemView, {
    37    sidebarItemIsDisabled,
    38    SidebarItemRoot,
    39  } from "./SidebarItemView"
    40  import SidebarKeyboardShortcuts from "./SidebarKeyboardShortcuts"
    41  import { AnimDuration, Color, Font, FontSize, SizeUnit } from "./style-helpers"
    42  import { startBuild } from "./trigger"
    43  import { ResourceName, ResourceStatus, ResourceView } from "./types"
    44  import { useStarredResources } from "./StarredResourcesContext"
    45  
    46  export type SidebarProps = {
    47    items: SidebarItem[]
    48    selected: string
    49    resourceView: ResourceView
    50    pathBuilder: PathBuilder
    51    resourceListOptions: ResourceListOptions
    52  }
    53  
    54  type SidebarGroupedByProps = SidebarProps & {
    55    onStartBuild: () => void
    56  }
    57  
    58  type SidebarSectionProps = {
    59    sectionName?: string
    60    groupView?: boolean
    61  } & SidebarProps
    62  
    63  export let SidebarResourcesRoot = styled.nav`
    64    flex: 1 0 auto;
    65  
    66    &.isOverview {
    67      overflow: auto;
    68      flex-shrink: 1;
    69    }
    70  `
    71  
    72  let SidebarResourcesContent = styled.div`
    73    margin-bottom: ${SizeUnit(1.75)};
    74  `
    75  
    76  let SidebarListSectionName = styled.div`
    77    margin-top: ${SizeUnit(0.5)};
    78    margin-left: ${SizeUnit(0.5)};
    79    text-transform: uppercase;
    80    color: ${Color.gray50};
    81    font-size: ${FontSize.small};
    82  `
    83  
    84  const BuiltinResourceLinkRoot = styled(Link)`
    85    background-color: ${Color.gray40};
    86    border: 1px solid ${Color.gray50};
    87    border-radius: ${SizeUnit(1 / 8)};
    88    color: ${Color.white};
    89    display: block;
    90    font-family: ${Font.sansSerif};
    91    font-size: ${FontSize.smallest};
    92    font-weight: normal;
    93    margin: ${SizeUnit(1 / 3)} ${SizeUnit(1 / 2)};
    94    padding: ${SizeUnit(1 / 5)} ${SizeUnit(1 / 3)};
    95    text-decoration: none;
    96    transition: all ${AnimDuration.default} ease;
    97  
    98    &:is(:hover, :focus, :active) {
    99      background-color: ${Color.gray30};
   100    }
   101  
   102    &.isSelected {
   103      background-color: ${Color.gray70};
   104      color: ${Color.gray30};
   105      font-weight: 600;
   106    }
   107  `
   108  
   109  export const SidebarListSectionItemsRoot = styled.ul`
   110    margin-top: ${SizeUnit(0.25)};
   111    list-style: none;
   112  `
   113  
   114  export const SidebarDisabledSectionList = styled.li`
   115    color: ${Color.gray60};
   116    font-family: ${Font.sansSerif};
   117    font-size: ${FontSize.small};
   118  `
   119  
   120  export const SidebarDisabledSectionTitle = styled.span`
   121    display: inline-block;
   122    margin-bottom: ${SizeUnit(1 / 12)};
   123    margin-top: ${SizeUnit(1 / 3)};
   124    padding-left: ${SizeUnit(3 / 4)};
   125  `
   126  
   127  const NoMatchesFound = styled.li`
   128    margin-left: ${SizeUnit(0.5)};
   129    color: ${Color.grayLightest};
   130  `
   131  
   132  const SidebarLabelSection = styled(Accordion)`
   133    ${AccordionStyleResetMixin}
   134  
   135    /* Set specific margins for sidebar */
   136    &.MuiAccordion-root,
   137    &.MuiAccordion-root.Mui-expanded {
   138      margin: ${SizeUnit(1 / 3)} ${SizeUnit(1 / 2)};
   139    }
   140  `
   141  
   142  const SidebarGroupSummary = styled(AccordionSummary)`
   143    ${AccordionSummaryStyleResetMixin}
   144    ${ResourceGroupSummaryMixin}
   145  
   146    /* Set specific background and borders for sidebar */
   147    .MuiAccordionSummary-content {
   148      background-color: ${Color.gray40};
   149      border: 1px solid ${Color.gray50};
   150      border-radius: ${SizeUnit(1 / 8)};
   151      font-size: ${FontSize.small};
   152    }
   153  `
   154  
   155  export const SidebarGroupName = styled.span`
   156    margin-right: auto;
   157    overflow: hidden;
   158    text-overflow: ellipsis;
   159    width: 100%;
   160  `
   161  
   162  const SidebarGroupDetails = styled(AccordionDetails)`
   163    ${AccordionDetailsStyleResetMixin}
   164  
   165    &.MuiAccordionDetails-root {
   166      ${SidebarItemRoot} {
   167        margin-right: unset;
   168      }
   169    }
   170  `
   171  
   172  const GROUP_INFO_TOOLTIP_ID = "sidebar-groups-info"
   173  
   174  function onlyEnabledItems(items: SidebarItem[]): SidebarItem[] {
   175    return items.filter((item) => !sidebarItemIsDisabled(item))
   176  }
   177  function onlyDisabledItems(items: SidebarItem[]): SidebarItem[] {
   178    return items.filter((item) => sidebarItemIsDisabled(item))
   179  }
   180  function enabledItemsFirst(items: SidebarItem[]): SidebarItem[] {
   181    let result = onlyEnabledItems(items)
   182    result.push(...onlyDisabledItems(items))
   183    return result
   184  }
   185  
   186  function AllResourcesLink(props: {
   187    pathBuilder: PathBuilder
   188    selected: string
   189  }) {
   190    const isSelectedClass = props.selected === "" ? "isSelected" : ""
   191    return (
   192      <BuiltinResourceLinkRoot
   193        className={isSelectedClass}
   194        aria-label="View all resource logs"
   195        to={props.pathBuilder.encpath`/r/(all)/overview`}
   196      >
   197        All Resources
   198      </BuiltinResourceLinkRoot>
   199    )
   200  }
   201  
   202  function StarredResourcesLink(props: {
   203    pathBuilder: PathBuilder
   204    selected: string
   205  }) {
   206    const starContext = useStarredResources()
   207    if (!starContext.starredResources.length) {
   208      return null
   209    }
   210    const isSelectedClass =
   211      props.selected === ResourceName.starred ? "isSelected" : ""
   212    return (
   213      <BuiltinResourceLinkRoot
   214        className={isSelectedClass}
   215        aria-label="View starred resource logs"
   216        to={props.pathBuilder.encpath`/r/(starred)/overview`}
   217      >
   218        Starred Resources
   219      </BuiltinResourceLinkRoot>
   220    )
   221  }
   222  
   223  export function SidebarListSection(props: SidebarSectionProps): JSX.Element {
   224    const features = useFeatures()
   225    const sectionName = props.sectionName ? (
   226      <SidebarListSectionName>{props.sectionName}</SidebarListSectionName>
   227    ) : null
   228  
   229    const resourceNameFilterApplied =
   230      props.resourceListOptions.resourceNameFilter.length > 0
   231    if (props.items.length === 0 && resourceNameFilterApplied) {
   232      return (
   233        <>
   234          {sectionName}
   235          <SidebarListSectionItemsRoot>
   236            <NoMatchesFound>No matching resources</NoMatchesFound>
   237          </SidebarListSectionItemsRoot>
   238        </>
   239      )
   240    }
   241  
   242    // TODO(nick): Figure out how to memoize filters effectively.
   243    const enabledItems = onlyEnabledItems(props.items)
   244    const disabledItems = onlyDisabledItems(props.items)
   245  
   246    const displayDisabledResources = disabledItems.length > 0
   247  
   248    return (
   249      <>
   250        {sectionName}
   251        <SidebarListSectionItemsRoot>
   252          <SidebarListSectionItems {...props} items={enabledItems} />
   253  
   254          {displayDisabledResources && (
   255            <SidebarDisabledSectionList aria-label="Disabled resources">
   256              <SidebarDisabledSectionTitle>Disabled</SidebarDisabledSectionTitle>
   257              <ul>
   258                <SidebarListSectionItems {...props} items={disabledItems} />
   259              </ul>
   260            </SidebarDisabledSectionList>
   261          )}
   262        </SidebarListSectionItemsRoot>
   263      </>
   264    )
   265  }
   266  
   267  const ShowMoreRow = styled.li`
   268    margin: ${SizeUnit(0.5)} ${SizeUnit(0.5)} 0 ${SizeUnit(0.5)};
   269    display: flex;
   270    align-items: center;
   271    justify-content: right;
   272  `
   273  
   274  function SidebarListSectionItems(props: SidebarSectionProps) {
   275    let [maxItems, setMaxItems] = useState(DEFAULT_RESOURCE_LIST_LIMIT)
   276    let displayItems = props.items
   277    let moreItems = Math.max(displayItems.length - maxItems, 0)
   278    if (moreItems) {
   279      displayItems = displayItems.slice(0, maxItems)
   280    }
   281  
   282    let showMore = useCallback(() => {
   283      setMaxItems(maxItems * RESOURCE_LIST_MULTIPLIER)
   284    }, [maxItems, setMaxItems])
   285  
   286    let showMoreItemsButton = null
   287    if (moreItems > 0) {
   288      showMoreItemsButton = (
   289        <ShowMoreRow>
   290          <ShowMoreButton
   291            onClick={showMore}
   292            currentListSize={maxItems}
   293            itemCount={props.items.length}
   294          />
   295        </ShowMoreRow>
   296      )
   297    }
   298  
   299    return (
   300      <>
   301        {displayItems.map((item) => (
   302          <SidebarItemView
   303            key={"sidebarItem-" + item.name}
   304            groupView={props.groupView}
   305            item={item}
   306            selected={props.selected === item.name}
   307            pathBuilder={props.pathBuilder}
   308            resourceView={props.resourceView}
   309          />
   310        ))}
   311        {showMoreItemsButton}
   312      </>
   313    )
   314  }
   315  
   316  function SidebarGroupListSection(props: { label: string } & SidebarProps) {
   317    if (props.items.length === 0) {
   318      return null
   319    }
   320  
   321    const formattedLabel =
   322      props.label === UNLABELED_LABEL ? <em>{props.label}</em> : props.label
   323    const labelNameId = `sidebarItem-${props.label}`
   324  
   325    const { getGroup, toggleGroupExpanded } = useResourceGroups()
   326    let { expanded } = getGroup(props.label)
   327  
   328    let isSelected = props.items.some((item) => item.name == props.selected)
   329  
   330    if (isSelected) {
   331      // If an item in the group is selected, expand the group
   332      // without writing it back to persistent state.
   333      //
   334      // This creates a nice interaction, where if you're keyboard-navigating
   335      // through sidebar items, we expand the group you navigate into and expand
   336      // it when you navigate out again.
   337      expanded = true
   338    }
   339  
   340    const handleChange = (_e: ChangeEvent<{}>) => toggleGroupExpanded(props.label)
   341  
   342    // TODO (lizz): Improve the accessibility interface for accordion feature by adding focus styles
   343    // according to https://www.w3.org/TR/wai-aria-practices-1.1/examples/accordion/accordion.html
   344    return (
   345      <SidebarLabelSection expanded={expanded} onChange={handleChange}>
   346        <SidebarGroupSummary id={labelNameId}>
   347          <ResourceGroupSummaryIcon role="presentation" />
   348          <SidebarGroupName>{formattedLabel}</SidebarGroupName>
   349          <SidebarGroupStatusSummary
   350            labelText={`Status summary for ${props.label} group`}
   351            resources={props.items}
   352          />
   353        </SidebarGroupSummary>
   354        <SidebarGroupDetails aria-labelledby={labelNameId}>
   355          <SidebarListSection {...props} />
   356        </SidebarGroupDetails>
   357      </SidebarLabelSection>
   358    )
   359  }
   360  
   361  function resourcesLabelView(
   362    items: SidebarItem[]
   363  ): GroupByLabelView<SidebarItem> {
   364    const labelsToResources: { [key: string]: SidebarItem[] } = {}
   365    const unlabeled: SidebarItem[] = []
   366    const tiltfile: SidebarItem[] = []
   367  
   368    items.forEach((item) => {
   369      if (item.labels.length) {
   370        item.labels.forEach((label) => {
   371          if (!labelsToResources.hasOwnProperty(label)) {
   372            labelsToResources[label] = []
   373          }
   374  
   375          labelsToResources[label].push(item)
   376        })
   377      } else if (item.isTiltfile) {
   378        tiltfile.push(item)
   379      } else {
   380        unlabeled.push(item)
   381      }
   382    })
   383  
   384    // Labels are always displayed in sorted order
   385    const labels = orderLabels(Object.keys(labelsToResources))
   386  
   387    return { labels, labelsToResources, tiltfile, unlabeled }
   388  }
   389  
   390  function SidebarGroupedByLabels(props: SidebarGroupedByProps) {
   391    const { labels, labelsToResources, tiltfile, unlabeled } = resourcesLabelView(
   392      props.items
   393    )
   394  
   395    // NOTE(nick): We need the visual order of the items to pass
   396    // to the keyboard navigation component. The problem is that
   397    // each section component does its own ordering. So we cheat
   398    // here and replicate the logic for determining the order.
   399    let totalOrder: SidebarItem[] = []
   400    labels.map((label) => {
   401      totalOrder.push(...enabledItemsFirst(labelsToResources[label]))
   402    })
   403    totalOrder.push(...enabledItemsFirst(unlabeled))
   404    totalOrder.push(...enabledItemsFirst(tiltfile))
   405  
   406    return (
   407      <>
   408        {labels.map((label) => (
   409          <SidebarGroupListSection
   410            {...props}
   411            key={`sidebarItem-${label}`}
   412            label={label}
   413            items={labelsToResources[label]}
   414          />
   415        ))}
   416        <SidebarGroupListSection
   417          {...props}
   418          label={UNLABELED_LABEL}
   419          items={unlabeled}
   420        />
   421        <SidebarListSection
   422          {...props}
   423          sectionName={TILTFILE_LABEL}
   424          items={tiltfile}
   425          groupView={true}
   426        />
   427        <SidebarKeyboardShortcuts
   428          selected={props.selected}
   429          items={totalOrder}
   430          onStartBuild={props.onStartBuild}
   431          resourceView={props.resourceView}
   432        />
   433      </>
   434    )
   435  }
   436  
   437  function hasAlerts(item: SidebarItem): boolean {
   438    return item.buildAlertCount > 0 || item.runtimeAlertCount > 0
   439  }
   440  
   441  function sortByHasAlerts(itemA: SidebarItem, itemB: SidebarItem): number {
   442    return Number(hasAlerts(itemB)) - Number(hasAlerts(itemA))
   443  }
   444  
   445  function applyOptionsToItems(
   446    items: SidebarItem[],
   447    options: ResourceListOptions
   448  ): SidebarItem[] {
   449    let itemsToDisplay: SidebarItem[] = [...items]
   450  
   451    const itemsShouldBeFiltered =
   452      options.resourceNameFilter.length > 0 || !options.showDisabledResources
   453  
   454    if (itemsShouldBeFiltered) {
   455      itemsToDisplay = itemsToDisplay.filter((item) => {
   456        const itemIsDisabled = item.runtimeStatus === ResourceStatus.Disabled
   457        if (!options.showDisabledResources && itemIsDisabled) {
   458          return false
   459        }
   460  
   461        if (options.resourceNameFilter) {
   462          return matchesResourceName(item.name, options.resourceNameFilter)
   463        }
   464  
   465        return true
   466      })
   467    }
   468  
   469    if (options.alertsOnTop) {
   470      itemsToDisplay.sort(sortByHasAlerts)
   471    }
   472  
   473    return itemsToDisplay
   474  }
   475  
   476  export class SidebarResources extends React.Component<SidebarProps> {
   477    constructor(props: SidebarProps) {
   478      super(props)
   479      this.startBuildOnSelected = this.startBuildOnSelected.bind(this)
   480    }
   481  
   482    static contextType = FeaturesContext
   483  
   484    startBuildOnSelected() {
   485      if (this.props.selected) {
   486        startBuild(this.props.selected)
   487      }
   488    }
   489  
   490    render() {
   491      const filteredItems = applyOptionsToItems(
   492        this.props.items,
   493        this.props.resourceListOptions
   494      )
   495  
   496      // only say no matches if there were actually items that got filtered out
   497      // otherwise, there might just be 0 resources because there are 0 resources
   498      // (though technically there's probably always at least a Tiltfile resource)
   499      const resourceFilterApplied =
   500        this.props.resourceListOptions.resourceNameFilter.length > 0
   501      const sidebarName = resourceFilterApplied
   502        ? `${filteredItems.length} result${filteredItems.length === 1 ? "" : "s"}`
   503        : "resources"
   504  
   505      let isOverviewClass =
   506        this.props.resourceView === ResourceView.OverviewDetail
   507          ? "isOverview"
   508          : ""
   509  
   510      const labelsEnabled: boolean = this.context.isEnabled(Flag.Labels)
   511      const resourcesHaveLabels = this.props.items.some(
   512        (item) => item.labels.length > 0
   513      )
   514  
   515      // The label group tip is only displayed if labels are enabled but not used
   516      const displayLabelGroupsTip = labelsEnabled && !resourcesHaveLabels
   517      // The label group view does not display if a resource name filter is applied
   518      const displayLabelGroups =
   519        !resourceFilterApplied && labelsEnabled && resourcesHaveLabels
   520  
   521      return (
   522        <SidebarResourcesRoot
   523          aria-label="Resource logs"
   524          className={`Sidebar-resources ${isOverviewClass}`}
   525        >
   526          {displayLabelGroupsTip && (
   527            <ResourceGroupsInfoTip idForIcon={GROUP_INFO_TOOLTIP_ID} />
   528          )}
   529          <SidebarResourcesContent
   530            aria-describedby={
   531              displayLabelGroupsTip ? GROUP_INFO_TOOLTIP_ID : undefined
   532            }
   533          >
   534            <OverviewSidebarOptions items={filteredItems} />
   535            <AllResourcesLink
   536              pathBuilder={this.props.pathBuilder}
   537              selected={this.props.selected}
   538            />
   539            <StarredResourcesLink
   540              pathBuilder={this.props.pathBuilder}
   541              selected={this.props.selected}
   542            />
   543            {displayLabelGroups ? (
   544              <SidebarGroupedByLabels
   545                {...this.props}
   546                items={filteredItems}
   547                onStartBuild={this.startBuildOnSelected}
   548              />
   549            ) : (
   550              <SidebarListSection
   551                {...this.props}
   552                sectionName={sidebarName}
   553                items={filteredItems}
   554              />
   555            )}
   556          </SidebarResourcesContent>
   557          {/* The label groups display handles the keyboard shortcuts separately. */}
   558          {displayLabelGroups ? null : (
   559            <SidebarKeyboardShortcuts
   560              selected={this.props.selected}
   561              items={enabledItemsFirst(filteredItems)}
   562              onStartBuild={this.startBuildOnSelected}
   563              resourceView={this.props.resourceView}
   564            />
   565          )}
   566        </SidebarResourcesRoot>
   567      )
   568    }
   569  }
   570  
   571  export default SidebarResources