diff --git a/e2e/scripts/st_collapsible_container.py b/e2e/scripts/st_collapsible_container.py new file mode 100644 index 000000000000..e7096401840d --- /dev/null +++ b/e2e/scripts/st_collapsible_container.py @@ -0,0 +1,24 @@ +# Copyright 2018-2020 Streamlit Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import streamlit as st + +container = st.container() +container.write("I cannot collapse") + +collapsible = st.collapsible_container("Collapse me!") +collapsible.write("I can collapse") + +collapsed = st.collapsible_container("Expand me!", collapsed=True) +collapsed.write("I am already collapsed") diff --git a/e2e/specs/st_collapsible_container.spec.ts b/e2e/specs/st_collapsible_container.spec.ts new file mode 100644 index 000000000000..61c2fe03b81c --- /dev/null +++ b/e2e/specs/st_collapsible_container.spec.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2018-2020 Streamlit Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// + +const toggleIdentifier = "small[data-toggle]"; + +describe("st.collapsible_container", () => { + before(() => { + cy.visit("http://localhost:3000/"); + }); + + it("displays collapsible + regular containers properly", () => { + cy.get(".stBlock") + .first() + .within(() => { + cy.get(toggleIdentifier).should("not.exist"); + }); + cy.get(".stBlock") + .eq(1) + .within(() => { + cy.get(toggleIdentifier).should("exist"); + }); + cy.get(".stBlock") + .eq(2) + .within(() => { + cy.get(toggleIdentifier).should("exist"); + }); + }); + + it("collapses + expands", () => { + // Starts expanded + cy.get(".stBlock") + .eq(1) + .within(() => { + let toggle = cy.get(toggleIdentifier); + toggle.should("exist"); + toggle.should("have.text", "Hide"); + toggle.click(); + + toggle = cy.get(toggleIdentifier); + toggle.should("have.text", "Show"); + }); + // Starts collapsed + cy.get(".stBlock") + .eq(2) + .within(() => { + let toggle = cy.get(toggleIdentifier); + toggle.should("exist"); + toggle.should("have.text", "Show"); + toggle.click(); + + toggle = cy.get(toggleIdentifier); + toggle.should("have.text", "Hide"); + }); + }); +}); diff --git a/frontend/src/assets/css/variables.scss b/frontend/src/assets/css/variables.scss index ee659746993c..3e05734bd368 100644 --- a/frontend/src/assets/css/variables.scss +++ b/frontend/src/assets/css/variables.scss @@ -67,6 +67,9 @@ $font-size-sm: 0.8rem; $line-height-base: 1.6; $line-height-tight: 1.25; +// Spacing +$spacer: 1rem; + // Overwrite other theme settings. $border-radius: 0.25rem; $border-radius-sm: 0.25rem; diff --git a/frontend/src/components/core/Block/Block.tsx b/frontend/src/components/core/Block/Block.tsx index b240c706e7ba..6cefa7cbcbc6 100644 --- a/frontend/src/components/core/Block/Block.tsx +++ b/frontend/src/components/core/Block/Block.tsx @@ -22,7 +22,7 @@ import { dispatchOneOf } from "lib/immutableProto" import { ReportRunState } from "lib/ReportRunState" import { WidgetStateManager } from "lib/WidgetStateManager" import { makeElementWithInfoText } from "lib/utils" -import { IForwardMsgMetadata } from "autogen/proto" +import { IForwardMsgMetadata, IBlock } from "autogen/proto" import { ReportElement, BlockElement, SimpleElement } from "lib/DeltaParser" import { FileUploadClient } from "lib/FileUploadClient" @@ -42,6 +42,7 @@ import { } from "components/widgets/CustomComponent/" import Maybe from "components/core/Maybe/" +import withCollapsible from "hocs/withCollapsible" // Lazy-load elements. const Audio = React.lazy(() => import("components/elements/Audio/")) @@ -94,21 +95,30 @@ interface Props { uploadClient: FileUploadClient widgetsDisabled: boolean componentRegistry: ComponentRegistry + deltaBlock?: IBlock } class Block extends PureComponent { + private WithCollapsibleBlock = withCollapsible(Block) + + /** Recursively transform this BLockElement and all children to React Nodes. */ private renderElements = (width: number): ReactNode[] => { const elementsToRender = this.props.elements - // Transform Streamlit elements into ReactNodes. return elementsToRender .toArray() .map((reportElement: ReportElement, index: number): ReactNode | null => { const element = reportElement.get("element") if (element instanceof List) { - return this.renderBlock(element as BlockElement, index, width) + return this.renderBlock( + element as BlockElement, + index, + width, + reportElement.get("deltaBlock").toJS() + ) } + // Base case AKA a single element AKA leaf node in the render tree return this.renderElementWithErrorBoundary(reportElement, index, width) }) .filter((node: ReactNode | null): ReactNode => node != null) @@ -129,11 +139,16 @@ class Block extends PureComponent { private renderBlock( element: BlockElement, index: number, - width: number + width: number, + deltaBlock: IBlock ): ReactNode { + const BlockType = deltaBlock.collapsible + ? this.WithCollapsibleBlock + : Block + const optionalProps = deltaBlock.collapsible ? deltaBlock.collapsible : {} return (
- { uploadClient={this.props.uploadClient} widgetsDisabled={this.props.widgetsDisabled} componentRegistry={this.props.componentRegistry} + deltaBlock={deltaBlock} + {...optionalProps} />
) diff --git a/frontend/src/hocs/withCollapsible/index.tsx b/frontend/src/hocs/withCollapsible/index.tsx new file mode 100644 index 000000000000..e7370d220d79 --- /dev/null +++ b/frontend/src/hocs/withCollapsible/index.tsx @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2018-2020 Streamlit Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from "./withCollapsible" diff --git a/frontend/src/hocs/withCollapsible/withCollapsible.test.tsx b/frontend/src/hocs/withCollapsible/withCollapsible.test.tsx new file mode 100644 index 000000000000..647de37700bd --- /dev/null +++ b/frontend/src/hocs/withCollapsible/withCollapsible.test.tsx @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2018-2020 Streamlit Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ComponentType } from "react" +import { shallow } from "enzyme" +import withCollapsible, { Props, StyledToggle } from "./withCollapsible" + +const testComponent: ComponentType = () =>
test
+ +const getProps = (props: Partial = {}): Props => ({ + label: "hi", + collapsible: true, + ...props, +}) + +describe("withCollapsible HOC", () => { + it("renders without crashing", () => { + const props = getProps() + const WithHoc = withCollapsible(testComponent) + // @ts-ignore + const wrapper = shallow() + + expect(wrapper.html()).not.toBeNull() + }) + + it("should render a expanded component", () => { + const props = getProps() + const WithHoc = withCollapsible(testComponent) + // @ts-ignore + const wrapper = shallow() + const toggleHeader = wrapper.find(StyledToggle) + + expect(toggleHeader.exists()).toBeTruthy() + expect(toggleHeader.text()).toEqual("Hide") + }) + + it("should render a collapsed component", () => { + const props = getProps({ + collapsed: true, + }) + const WithHoc = withCollapsible(testComponent) + // @ts-ignore + const wrapper = shallow() + const toggleHeader = wrapper.find(StyledToggle) + + expect(toggleHeader.text()).toEqual("Show") + }) +}) diff --git a/frontend/src/hocs/withCollapsible/withCollapsible.tsx b/frontend/src/hocs/withCollapsible/withCollapsible.tsx new file mode 100644 index 000000000000..4a0b4840fd5b --- /dev/null +++ b/frontend/src/hocs/withCollapsible/withCollapsible.tsx @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2018-2020 Streamlit Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ComponentType, ReactElement, useEffect, useState } from "react" +import { styled } from "styletron-react" +import { colors, variables } from "lib/widgetTheme" + +export interface Props { + collapsible: boolean + label: string + collapsed: boolean +} + +type ComponentProps = { + collapsed: boolean +} + +export const AnimatedComponentWrapper = styled( + "div", + ({ collapsed }: ComponentProps) => ({ + maxHeight: collapsed ? 0 : "100vh", + overflow: "hidden", + transitionProperty: "max-height", + transitionDuration: "0.5s", + transitionTimingFunction: "ease-in-out", + }) +) + +export const StyledHeader = styled("div", ({ collapsed }: ComponentProps) => ({ + display: "flex", + justifyContent: "space-between", + cursor: "pointer", + borderWidth: 0, + borderBottomWidth: collapsed ? 0 : "1px", + borderStyle: "solid", + borderColor: colors.grayLighter, + marginBottom: variables.spacer, + transitionProperty: "border-bottom-width", + transitionDuration: "0.5s", + transitionTimingFunction: "ease-in-out", +})) + +export const StyledToggle = styled("small", { + color: colors.gray, +}) + +function withCollapsible( + WrappedComponent: ComponentType +): ComponentType { + const CollapsibleComponent = (props: Props): ReactElement => { + const { label, collapsed: initialCollapsed, ...componentProps } = props + + const [collapsed, toggleCollapse] = useState(initialCollapsed) + useEffect(() => { + toggleCollapse(initialCollapsed) + }, [initialCollapsed]) + + const toggle = (): void => toggleCollapse(!collapsed) + + return ( + <> + +
{label}
+ + {collapsed ? "Show" : "Hide"} + +
+ + + + + ) + } + + return CollapsibleComponent +} + +export default withCollapsible diff --git a/frontend/src/lib/DeltaParser.ts b/frontend/src/lib/DeltaParser.ts index 24fd39145e7d..3dfa63838be2 100644 --- a/frontend/src/lib/DeltaParser.ts +++ b/frontend/src/lib/DeltaParser.ts @@ -83,11 +83,11 @@ export function applyDelta( handleNewElementMessage(currentElement, element, reportId, metadata) ) }, - newBlock: () => { + addBlock: (deltaBlock: Delta) => { elements[topLevelBlock] = elements[topLevelBlock].updateIn( deltaPath, reportElement => - handleNewBlockMessage(reportElement, reportId, metadata) + handleAddBlockMessage(reportElement, reportId, metadata, deltaBlock) ) }, addRows: (namedDataSet: NamedDataSet) => { @@ -129,25 +129,21 @@ function handleNewElementMessage( }) } -function handleNewBlockMessage( +function handleAddBlockMessage( reportElement: ReportElement, reportId: string, - metadata: IForwardMsgMetadata + metadata: IForwardMsgMetadata, + deltaBlock: Delta ): ReportElement { MetricsManager.current.incrementDeltaCounter("new block") - // There's nothing at this node (aka first run), so initialize an empty list. - if (!reportElement) { - return ImmutableMap({ element: List(), reportId, metadata }) - } - - // This node was already a list of elements; no need to change anything. - if (reportElement.get("element") instanceof List) { - return reportElement - } + // This node was already a list of elements. Update everything but the element. + const list = + reportElement && reportElement.get("element") instanceof List + ? reportElement.get("element") + : List() - // This node used to represent a single element; convert into an empty list. - return reportElement.set("element", List()) + return ImmutableMap({ element: list, reportId, metadata, deltaBlock }) } function handleAddRowsMessage( diff --git a/frontend/src/lib/widgetTheme.ts b/frontend/src/lib/widgetTheme.ts index bd80d0e9fdde..9c7799b08788 100644 --- a/frontend/src/lib/widgetTheme.ts +++ b/frontend/src/lib/widgetTheme.ts @@ -34,6 +34,12 @@ const lineHeightTight = SCSS_VARS["$line-height-tight"] const smallTextMargin = SCSS_VARS["$m2-3-font-size-sm"] const textMargin = SCSS_VARS["$font-size-sm"] const tinyTextMargin = SCSS_VARS["$m1-2-font-size-sm"] +const spacer = SCSS_VARS.$spacer + +export const variables = { + borderRadius, + spacer, +} // Colors export const colors = { diff --git a/lib/streamlit/__init__.py b/lib/streamlit/__init__.py index db7ebea7dcf1..485aa04f3616 100644 --- a/lib/streamlit/__init__.py +++ b/lib/streamlit/__init__.py @@ -181,6 +181,7 @@ def _update_logger(): write = _main.write # noqa: E221 beta_color_picker = _main.beta_color_picker # noqa: E221 container = _main.container # noqa: E221 +collapsible_container = _main.collapsible_container # noqa: E221 # Config diff --git a/lib/streamlit/delta_generator.py b/lib/streamlit/delta_generator.py index ce5f9925ebf4..92fb26224ecc 100644 --- a/lib/streamlit/delta_generator.py +++ b/lib/streamlit/delta_generator.py @@ -22,6 +22,7 @@ from streamlit.report_thread import get_report_ctx from streamlit.errors import StreamlitAPIException, StreamlitDeprecationWarning from streamlit.errors import NoSessionContext +from streamlit.proto import Block_pb2 from streamlit.proto import BlockPath_pb2 from streamlit.proto import ForwardMsg_pb2 from streamlit.proto.Element_pb2 import Element @@ -352,7 +353,7 @@ def _enqueue( return _value_or_dg(return_value, output_dg) - def container(self): + def _block(self, block_proto=Block_pb2.Block()): # Switch to the active DeltaGenerator, in case we're in a `with` block. self = self._active_dg @@ -360,10 +361,10 @@ def container(self): return self msg = ForwardMsg_pb2.ForwardMsg() - msg.delta.new_block = True msg.metadata.parent_block.container = self._container msg.metadata.parent_block.path[:] = self._cursor.path msg.metadata.delta_id = self._cursor.index + msg.delta.add_block.CopyFrom(block_proto) # Normally we'd return a new DeltaGenerator that uses the locked cursor # below. But in this case we want to return a DeltaGenerator that uses @@ -377,13 +378,62 @@ def container(self): # Must be called to increment this cursor's index. self._cursor.get_locked_cursor(last_index=None) - _enqueue_message(msg) return block_dg + def container(self): + return self._block() + + def collapsible_container(self, label=None, collapsed=False): + """Creates a collapsible container. + + [TODO: get more container verbage] + Similar to `st.container`, `st.collapsible_container` provides a container + to add elements to. However, it has the added benefit of being collapsible. + Users will be able to expand and collapse the container that is identifiable + with the provided label. + + Parameters + ---------- + label : str + A short label used as the header for the collapsible container. + This will always be displayed even when the container is collapsed. + collapsed : boolean + The default state for the collapsible container. + Defaults to False + + Returns + ------- + [TODO] Technically a delta generator but let's please not tell the users that... + + Examples + -------- + >>> collapsible_container = st.collapsible_container("Collapse Me") + >>> collapsible_container.write("I can be collapsed") + + """ + if label is None: + raise StreamlitAPIException( + "A label is required for a collapsible container" + ) + + collapsible_proto = Block_pb2.Block.Collapsible() + collapsible_proto.collapsed = collapsed + collapsible_proto.label = label + + block_proto = Block_pb2.Block() + block_proto.collapsible.CopyFrom(collapsible_proto) + + return self._block(block_proto=block_proto) + def favicon( - self, element, image, clamp=False, channels="RGB", format="JPEG", + self, + element, + image, + clamp=False, + channels="RGB", + format="JPEG", ): """Set the page favicon to the specified image. diff --git a/lib/streamlit/report_queue.py b/lib/streamlit/report_queue.py index 2cc7b6398fe7..893bf1ad3563 100644 --- a/lib/streamlit/report_queue.py +++ b/lib/streamlit/report_queue.py @@ -133,7 +133,7 @@ def compose_deltas(old_delta, new_delta): if new_delta_type == "new_element": return new_delta - elif new_delta_type == "new_block": + elif new_delta_type == "add_block": return new_delta elif new_delta_type == "add_rows": diff --git a/proto/streamlit/proto/Block.proto b/proto/streamlit/proto/Block.proto new file mode 100644 index 000000000000..fa5857314c1a --- /dev/null +++ b/proto/streamlit/proto/Block.proto @@ -0,0 +1,26 @@ +/** + * Copyright 2018-2020 Streamlit Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +syntax = "proto3"; + +message Block { + Collapsible collapsible = 1; + + message Collapsible { + string label = 1; + bool collapsed = 2; + } +} diff --git a/proto/streamlit/proto/Delta.proto b/proto/streamlit/proto/Delta.proto index 148145d496da..49478ea03e57 100644 --- a/proto/streamlit/proto/Delta.proto +++ b/proto/streamlit/proto/Delta.proto @@ -16,6 +16,7 @@ syntax = "proto3"; +import "streamlit/proto/Block.proto"; import "streamlit/proto/Element.proto"; import "streamlit/proto/NamedDataSet.proto"; @@ -26,7 +27,7 @@ message Delta { Element new_element = 3; // Append a new block to the report. - bool new_block = 4; + Block add_block = 6; // Append data to a DataFrame in for current element. The element to add to // is identified by the ID field, above. The dataframe is identified either