Create atomic design page template for master-detail views

This commit is contained in:
Konstantin Schaper
2023-09-06 10:00:00 +02:00
parent bf28b75941
commit fa536b9768
16 changed files with 622 additions and 378 deletions

View File

@@ -0,0 +1,2 @@
- type: added
description: Atomic design page template simple data pages

View File

@@ -27,7 +27,7 @@ import classNames from "classnames";
type NotificationType = "primary" | "info" | "success" | "warning" | "danger" | "inherit";
type Props = {
type: NotificationType;
type?: NotificationType;
onClose?: () => void;
className?: string;
children?: ReactNode;

View File

@@ -18,6 +18,7 @@
"@scm-manager/tsconfig": "^2.13.0",
"@scm-manager/ui-styles": "2.46.2-SNAPSHOT",
"@scm-manager/ui-overlays": "2.46.2-SNAPSHOT",
"@scm-manager/ui-forms": "2.46.2-SNAPSHOT",
"@storybook/addon-actions": "^6.5.10",
"@storybook/addon-docs": "^6.5.14",
"@storybook/addon-essentials": "^6.5.10",
@@ -48,6 +49,7 @@
},
"dependencies": {
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-slot": "^1.0.1",
"@scm-manager/ui-buttons": "2.46.2-SNAPSHOT"
}
}

View File

@@ -0,0 +1,52 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { ComponentProps, ElementRef, ForwardRefExoticComponent, ReactElement } from "react";
import classNames from "classnames";
import { Slot } from "@radix-ui/react-slot";
const withClasses = <Element extends React.ElementType | ForwardRefExoticComponent<any>>(
typ: Element,
additionalClassNames: string[],
additionalProps?: Partial<ComponentProps<Element>>
) =>
React.forwardRef<
ElementRef<Element>,
| (ComponentProps<Element> & { asChild?: false; className?: string })
| { asChild: true; children: ReactElement<{ className?: string }> }
>((props, ref) => {
// @ts-ignore Typescript doesn't get it for some reason
if (props.asChild) {
return <Slot {...props} className={classNames(...additionalClassNames)} />;
} else {
return React.createElement(typ, {
...additionalProps,
...props,
className: classNames((props as any).className, ...additionalClassNames),
ref,
});
}
});
export default withClasses;

View File

@@ -59,7 +59,7 @@ const Card = React.forwardRef<HTMLElement, Props>(
},
avatar ? avatar : null,
<div
className="is-flex is-flex-direction-column is-justify-content-center is-flex-grow-1 is-clipped"
className="is-flex is-flex-direction-column is-justify-content-center is-flex-grow-1 is-overflow-wrap-anywhere"
style={{ gap: rowGap }}
>
{children}

View File

@@ -39,7 +39,7 @@ export const CardDetail = React.forwardRef<HTMLSpanElement, CardDetailProps>(
({ children, className, ...props }, ref) => {
const labelId = useGeneratedId();
return (
<span {...props} className={classNames("is-flex is-align-items-center", className)} ref={ref}>
<span {...props} className={classNames("is-flex is-align-items-center has-gap-1", className)} ref={ref}>
{typeof children === "function" ? children({ labelId }) : children}
</span>
);
@@ -52,7 +52,7 @@ export const CardDetail = React.forwardRef<HTMLSpanElement, CardDetailProps>(
*/
export const CardDetailLabel = React.forwardRef<HTMLSpanElement, HTMLAttributes<HTMLSpanElement>>(
({ children, className, ...props }, ref) => (
<span {...props} className={classNames("has-text-secondary is-size-7 mr-1", className)} ref={ref}>
<span {...props} className={classNames("has-text-secondary is-size-7", className)} ref={ref}>
{children}
</span>
)

View File

@@ -22,6 +22,9 @@
* SOFTWARE.
*/
import React, { ComponentProps } from "react";
import classNames from "classnames";
/**
* @beta
* @since 2.44.0
@@ -29,3 +32,13 @@
const CardRow = "div" as const;
export default CardRow;
export const SecondaryRow = React.forwardRef<HTMLDivElement, ComponentProps<typeof CardRow>>(
({ className, ...props }, ref) => <CardRow className={classNames(className, "is-size-7")} {...props} ref={ref} />
);
export const TertiaryRow = React.forwardRef<HTMLDivElement, ComponentProps<typeof CardRow>>(
({ className, ...props }, ref) => (
<CardRow className={classNames(className, "is-size-7", "has-text-secondary")} {...props} ref={ref} />
)
);

View File

@@ -24,15 +24,25 @@
import CardListComponent, { CardListBox as CardListBoxComponent, CardListCard } from "./card-list/CardList";
import CardTitle from "./card/CardTitle";
import CardRow from "./card/CardRow";
import CardRow, { SecondaryRow, TertiaryRow } from "./card/CardRow";
import { CardDetail, CardDetailLabel, CardDetails, CardDetailTag } from "./card/CardDetail";
import CardComponent from "./card/Card";
import {
DataPageHeader as DataPageHeaderComponent,
DataPageHeaderCreateButton,
DataPageHeaderSetting,
DataPageHeaderSettingField,
DataPageHeaderSettingLabel,
DataPageHeaderSettings,
} from "./templates/data-page/DataPageHeader";
export { default as Collapsible } from "./collapsible/Collapsible";
const CardExport = {
Title: CardTitle,
Row: CardRow,
SecondaryRow: SecondaryRow,
TertiaryRow: TertiaryRow,
Details: Object.assign(CardDetails, {
Detail: Object.assign(CardDetail, {
Label: CardDetailLabel,
@@ -49,3 +59,13 @@ const CardListExport = {
export const CardList = Object.assign(CardListComponent, CardListExport);
export const CardListBox = Object.assign(CardListBoxComponent, CardListExport);
export const DataPageHeader = Object.assign(DataPageHeaderComponent, {
Settings: Object.assign(DataPageHeaderSettings, {
Setting: Object.assign(DataPageHeaderSetting, {
Label: DataPageHeaderSettingLabel,
Field: DataPageHeaderSettingField,
}),
}),
CreateButton: DataPageHeaderCreateButton,
});

View File

@@ -0,0 +1,200 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import {
DataPageHeader,
DataPageHeaderCreateButton,
DataPageHeaderSetting,
DataPageHeaderSettingField,
DataPageHeaderSettingLabel,
DataPageHeaderSettings,
} from "./DataPageHeader";
import { Select } from "@scm-manager/ui-forms";
import { ComponentMeta, ComponentStory } from "@storybook/react";
import { ErrorNotification, Loading, Subtitle, Title, Notification } from "@scm-manager/ui-components";
import { Button, Icon } from "@scm-manager/ui-buttons";
import { CardListBox, CardListCard } from "../../card-list/CardList";
import CardRow, { SecondaryRow, TertiaryRow } from "../../card/CardRow";
import CardTitle from "../../card/CardTitle";
import { Link } from "react-router-dom";
import StoryRouter from "storybook-react-router";
import { CardDetail, CardDetailLabel, CardDetails, CardDetailTag } from "../../card/CardDetail";
export default {
title: "Data Page Template",
component: DataPageHeader,
decorators: [StoryRouter()],
} as ComponentMeta<typeof DataPageHeader>;
// @ts-ignore Storybook is not cooperating
export const Example: ComponentStory<{ error: Error; isLoading: boolean; isEmpty: boolean }> = ({
error,
isLoading,
isEmpty,
}: any) => {
let content;
if (error) {
content = <ErrorNotification error={error} />;
} else if (isLoading) {
content = <Loading />;
} else if (isEmpty) {
content = <Notification type="info">There is no data, consider adjusting the filters</Notification>;
} else {
content = (
<CardListBox>
<CardListCard avatar={<Icon>trash</Icon>} action={<Icon>ellipsis-v</Icon>}>
<CardRow>
<CardTitle>
<Link to="/item">
The title may contain a link but most importantly does not contain any information except the "display
name" of the entity. It is also text-only
</Link>
</CardTitle>
</CardRow>
<SecondaryRow>
This contains more important details about the card, but not quite as important as the title.
</SecondaryRow>
<TertiaryRow>This contains less important information about the card</TertiaryRow>
<CardRow>
<CardDetails>
<CardDetail>
<CardDetailLabel>Tags are great for numbers.</CardDetailLabel>
<CardDetailTag>7/3</CardDetailTag>
</CardDetail>
<CardDetail>
{({ labelId }) => (
<>
<CardDetailLabel id={labelId}>
Interactive details need 'is-relative' and 'aria-labelledby'
</CardDetailLabel>
<Button aria-labelledby={labelId} className="is-relative has-background-transparent is-borderless">
<Icon>edit</Icon>
</Button>
</>
)}
</CardDetail>
</CardDetails>
</CardRow>
</CardListCard>
<CardListCard avatar={<Icon>users</Icon>} action={<Icon>ellipsis-v</Icon>}>
<CardRow>
<CardTitle>
<Link to="/item">
We can also enter insane text without whitespace
ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss
</Link>
</CardTitle>
</CardRow>
<SecondaryRow>
SCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCM
</SecondaryRow>
<TertiaryRow>
SCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCM
</TertiaryRow>
<CardRow>
<CardDetails>
<CardDetail>
<CardDetailLabel>SCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCM</CardDetailLabel>
<CardDetailTag>7/3</CardDetailTag>
</CardDetail>
<CardDetail>
{({ labelId }) => (
<>
<CardDetailLabel id={labelId}>SCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCMSCM</CardDetailLabel>
<Button aria-labelledby={labelId} className="is-relative has-background-transparent is-borderless">
<Icon>trash</Icon>
</Button>
</>
)}
</CardDetail>
</CardDetails>
</CardRow>
</CardListCard>
</CardListBox>
);
}
return (
<>
<Title>My Page</Title>
<Subtitle subtitle="All the data" />
<DataPageHeader>
<DataPageHeaderSettings>
<DataPageHeaderSetting>
{({ formFieldId }) => (
<>
<DataPageHeaderSettingLabel htmlFor={formFieldId}>Filter by</DataPageHeaderSettingLabel>
<DataPageHeaderSettingField>
<Select
id={formFieldId}
options={[
{
label: "Yes",
value: 1,
},
{
label: "No",
value: 0,
},
]}
/>
</DataPageHeaderSettingField>
</>
)}
</DataPageHeaderSetting>
<DataPageHeaderSetting>
{({ formFieldId }) => (
<>
<DataPageHeaderSettingLabel htmlFor={formFieldId}>Sort by</DataPageHeaderSettingLabel>
<DataPageHeaderSettingField>
<Select
id={formFieldId}
options={[
{
label: "Blue",
value: 1,
},
{
label: "Red",
value: 0,
},
]}
/>
</DataPageHeaderSettingField>
</>
)}
</DataPageHeaderSetting>
</DataPageHeaderSettings>
<DataPageHeaderCreateButton to="/mydata/create">Create New Data</DataPageHeaderCreateButton>
</DataPageHeader>
{content}
</>
);
};
Example.args = {
error: undefined,
isLoading: false,
isEmpty: false,
};

View File

@@ -0,0 +1,100 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import withClasses from "../../_helpers/with-classes";
import React, { HTMLAttributes } from "react";
import classNames from "classnames";
import { useGeneratedId } from "@scm-manager/ui-components";
import { LinkButton } from "@scm-manager/ui-buttons";
/**
* @beta
* @since 2.47.0
*/
export const DataPageHeader = withClasses("div", [
"is-flex",
"is-flex-wrap-wrap",
"is-justify-content-space-between",
"mb-3",
"has-row-gap-2",
"has-column-gap-4",
]);
/**
* @beta
* @since 2.47.0
*/
export const DataPageHeaderSettings = withClasses("div", [
"is-flex",
"is-flex-wrap-wrap",
"is-align-items-center",
"has-row-gap-2",
"has-column-gap-4",
"is-flex-grow-1",
"is-flex-shrink-1",
"is-flex-basis-0",
]);
type DataPageHeaderSettingProps = HTMLAttributes<HTMLDivElement> & {
children?: React.ReactNode | ((props: { formFieldId: string }) => React.ReactNode);
};
/**
* @beta
* @since 2.47.0
*/
export const DataPageHeaderSetting = React.forwardRef<HTMLSpanElement, DataPageHeaderSettingProps>(
({ className, children, ...props }, ref) => {
const formFieldId = useGeneratedId();
return (
<span {...props} className={classNames(className, "is-flex", "is-align-items-center", "has-gap-2")} ref={ref}>
{typeof children === "function" ? children({ formFieldId }) : children}
</span>
);
}
);
/**
* @beta
* @since 2.47.0
*/
export const DataPageHeaderSettingLabel = withClasses("label", [
"is-flex",
"is-align-items-center",
"is-text-wrap-no-wrap",
]);
/**
* @beta
* @since 2.47.0
*/
export const DataPageHeaderSettingField = React.Fragment;
/**
* @beta
* @since 2.47.0
*/
export const DataPageHeaderCreateButton = withClasses(LinkButton, ["is-flex-grow-0", "is-flex-shrink-0"], {
variant: "primary",
});

View File

@@ -0,0 +1,29 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
@each $size, $value in $spacing-values {
.is-flex-basis-#{$size} {
flex-basis: $value;
}
}

View File

@@ -0,0 +1,35 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
@each $size, $value in $spacing-values {
.has-gap-#{$size} {
gap: $value;
}
.has-row-gap-#{$size} {
row-gap: $value;
}
.has-column-gap-#{$size} {
column-gap: $value;
}
}

View File

@@ -53,7 +53,13 @@
}
}
.is-overflow-wrap-anywhere {
overflow-wrap: anywhere;
}
.is-text-wrap-no-wrap {
text-wrap: nowrap;
}
.is-absolute {
position: absolute;

View File

@@ -25,5 +25,7 @@
@import "../variables/_derived.scss";
@import "bulma-popover/css/bulma-popover";
@import "../components/_main.scss";
@import "../components/_gap.scss";
@import "../components/_flex.scss";
@import "../components/_tooltip.scss";
@import "../components/_card.scss";

View File

@@ -30,19 +30,9 @@ import { useTranslation } from "react-i18next";
import { useBranchDetailsCollection } from "@scm-manager/ui-api";
import { KeyboardIterator } from "@scm-manager/ui-shortcuts";
import BranchList from "../components/BranchList";
import { Collapsible } from "@scm-manager/ui-layout";
import { LinkButton } from "@scm-manager/ui-buttons";
import { Collapsible, DataPageHeader } from "@scm-manager/ui-layout";
import { Select } from "@scm-manager/ui-forms";
import { SORT_OPTIONS, SortOption } from "../../tags/orderTags";
import styled from "styled-components";
const BranchListWrapper = styled.div`
gap: 1rem;
`;
const HeaderWrapper = styled.div`
gap: 0.5rem 1rem;
`;
type Props = {
repository: Repository;
@@ -74,26 +64,32 @@ const BranchTableWrapper: FC<Props> = ({ repository, baseUrl, data }) => {
<>
<Subtitle subtitle={t("branches.overview.title")} />
<ErrorNotification error={error} />
<HeaderWrapper className="is-flex is-flex-wrap-wrap is-justify-content-space-between mb-3">
<div className="is-flex is-align-items-center">
<label className="mr-2" htmlFor="branches-overview-sort">
<DataPageHeader>
<DataPageHeader.Settings className="is-flex is-align-items-center">
<DataPageHeader.Settings.Setting>
{({ formFieldId }) => (
<>
<DataPageHeader.Settings.Setting.Label htmlFor={formFieldId}>
{t("branches.overview.sort.label")}
</label>
<Select id="branches-overview-sort" onChange={(e) => setSort(e.target.value as SortOption)}>
</DataPageHeader.Settings.Setting.Label>
<DataPageHeader.Settings.Setting.Field>
<Select id={formFieldId} onChange={(e) => setSort(e.target.value as SortOption)}>
{SORT_OPTIONS.map((sortOption) => (
<option key={sortOption} value={sortOption}>
{t(`branches.overview.sort.option.${sortOption}`)}
</option>
))}
</Select>
</div>
</DataPageHeader.Settings.Setting.Field>
</>
)}
</DataPageHeader.Settings.Setting>
</DataPageHeader.Settings>
{showCreateButton ? (
<LinkButton variant="primary" to="./create">
{t("branches.overview.createButton")}
</LinkButton>
<DataPageHeader.CreateButton to="./create">{t("branches.overview.createButton")}</DataPageHeader.CreateButton>
) : null}
</HeaderWrapper>
<BranchListWrapper className="is-flex is-flex-direction-column">
</DataPageHeader>
<div className="is-flex is-flex-direction-column has-gap-4">
<KeyboardIterator>
{activeBranches.length > 0 ? (
<Collapsible header={t("branches.table.branches.active")}>
@@ -116,7 +112,7 @@ const BranchTableWrapper: FC<Props> = ({ repository, baseUrl, data }) => {
</Collapsible>
) : null}
</KeyboardIterator>
</BranchListWrapper>
</div>
</>
);
};