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