Refactor repository tags overview

This commit is contained in:
Konstantin Schaper
2023-05-02 15:49:48 +02:00
parent a185cc7d16
commit ea67a04b5d
36 changed files with 872 additions and 77 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -3,7 +3,9 @@ title: Repository
subtitle: Tags subtitle: Tags
--- ---
### Übersicht ### Übersicht
Auf der Tags-Übersicht sind die existierenden Tags nach Erstelldatum absteigend aufgeführt. Bei einem Klick auf einen Tag wird der Benutzer zur Detailseite des Tags weitergeleitet. Auf der Tags-Übersicht sind die existierenden Tags aufgeführt.
Die Tags sind standardmäßig nach Erstelldatum absteigend sortiert, können aber auch alphabetisch sortiert werden.
Bei einem Klick auf einen Tag wird der Benutzer zur Detailseite des Tags weitergeleitet.
![Tags Übersicht](assets/repository-tags-overview.png) ![Tags Übersicht](assets/repository-tags-overview.png)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -3,7 +3,9 @@ title: Repository
subtitle: Tags subtitle: Tags
--- ---
### Overview ### Overview
The tag overview shows the tags that exist for this repository. By clicking on a tag, the details page of the tag is shown. The tag overview shows the tags that exist for this repository.
The tags are sorted by creation date in descending order by default, but can also be sorted alphabetically.
By clicking on a tag, the details page of the tag is shown.
![Tags Overview](assets/repository-tags-overview.png) ![Tags Overview](assets/repository-tags-overview.png)

View File

@@ -0,0 +1,4 @@
- type: added
description: New card list component
- type: changed
description: Revamp repository tags overview

View File

@@ -60,7 +60,9 @@ type BaseButtonProps = {
type ButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLButtonElement>; type ButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLButtonElement>;
/** /**
* Styled html button * Styled html button.
*
* A button has to declare an `aria-label` if it exclusively contains an {@link Icon} as its children.
* *
* @beta * @beta
* @since 2.41.0 * @since 2.41.0
@@ -82,7 +84,9 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
type LinkButtonProps = BaseButtonProps & ReactRouterLinkProps; type LinkButtonProps = BaseButtonProps & ReactRouterLinkProps;
/** /**
* Styled react router link * Styled react router link.
*
* A button has to declare an `aria-label` if it exclusively contains an {@link Icon} as its children.
* *
* @beta * @beta
* @since 2.41.0 * @since 2.41.0
@@ -100,27 +104,40 @@ export const LinkButton = React.forwardRef<HTMLAnchorElement, LinkButtonProps>(
) )
); );
type ExternalLinkButtonProps = BaseButtonProps & AnchorHTMLAttributes<HTMLAnchorElement>;
/** /**
* Styled html anchor.
*
* External links open in a new browser tab with rel flags "noopener" and "noreferrer" set by default. * External links open in a new browser tab with rel flags "noopener" and "noreferrer" set by default.
* *
* @beta * @beta
* @since 2.44.0
*/
export const ExternalLink = React.forwardRef<HTMLAnchorElement, AnchorHTMLAttributes<HTMLAnchorElement>>(
({ children, ...props }, ref) => (
<a target="_blank" rel="noreferrer noopener" {...props} ref={ref}>
{children}
</a>
)
);
type ExternalLinkButtonProps = BaseButtonProps & AnchorHTMLAttributes<HTMLAnchorElement>;
/**
* Styled {@link ExternalLink}.
*
* A button has to declare an `aria-label` if it exclusively contains an {@link Icon} as its children.
*
* @beta
* @since 2.41.0 * @since 2.41.0
* @see ExternalLink
*/ */
export const ExternalLinkButton = React.forwardRef<HTMLAnchorElement, ExternalLinkButtonProps>( export const ExternalLinkButton = React.forwardRef<HTMLAnchorElement, ExternalLinkButtonProps>(
({ className, variant, isLoading, testId, children, ...props }, ref) => ( ({ className, variant, isLoading, testId, children, ...props }, ref) => (
<a <ExternalLink
target="_blank"
rel="noreferrer noopener"
{...props} {...props}
className={classNames(createButtonClasses(variant, isLoading), className)} className={classNames(createButtonClasses(variant, isLoading), className)}
ref={ref} ref={ref}
{...createAttributesForTesting(testId)} {...createAttributesForTesting(testId)}
> >
{children} {children}
</a> </ExternalLink>
) )
); );

View File

@@ -30,13 +30,20 @@ type Props = React.HTMLProps<HTMLElement> & {
}; };
/** /**
* Icons are hidden to assistive technologies by default.
*
* If your icon does convey a state, unset `aria-hidden` and set an appropriate `aria-label`.
*
* The children have to be a single text node containing a valid fontawesome icon name.
*
* @beta * @beta
* @since 2.44.0 * @since 2.44.0
* @see https://bulma.io/documentation/elements/icon/ * @see https://bulma.io/documentation/elements/icon/
* @see https://fontawesome.com/search?o=r&m=free
*/ */
const Icon = React.forwardRef<HTMLElement, Props>(({ children, className, ...props }, ref) => { const Icon = React.forwardRef<HTMLElement, Props>(({ children, className, ...props }, ref) => {
return ( return (
<span className={classNames(className, "icon")} {...props} ref={ref}> <span className={classNames(className, "icon")} aria-hidden="true" {...props} ref={ref}>
<i <i
className={classNames(`fas fa-fw fa-${children}`, { className={classNames(`fas fa-fw fa-${children}`, {
"fa-xs": className?.includes("is-small"), "fa-xs": className?.includes("is-small"),

View File

@@ -22,5 +22,5 @@
* SOFTWARE. * SOFTWARE.
*/ */
export { Button, LinkButton, ExternalLinkButton, ButtonVariants } from "./Button"; export { Button, LinkButton, ExternalLinkButton, ExternalLink, ButtonVariants } from "./Button";
export { default as Icon } from "./Icon"; export { default as Icon } from "./Icon";

View File

@@ -0,0 +1,4 @@
{
"presets": ["@scm-manager/babel-preset"],
"plugins": ["@babel/plugin-syntax-dynamic-import"]
}

View File

@@ -0,0 +1,57 @@
/*
* 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.
*/
const HtmlWebpackPlugin = require('html-webpack-plugin');
class RemoveThemesPlugin {
apply (compiler) {
compiler.hooks.compilation.tap('RemoveThemesPlugin', (compilation) => {
HtmlWebpackPlugin.getHooks(compilation).beforeAssetTagGeneration.tapAsync(
'RemoveThemesPlugin',
(data, cb) => {
// remove generated style-loader bundles from the page
// there should be a better way, which does not generate the bundles at all
// but for now it works
if (data.assets.js) {
data.assets.js = data.assets.js.filter(bundle => !bundle.startsWith("ui-theme-"))
.filter(bundle => !bundle.startsWith("runtime~ui-theme-"))
}
// remove css links to avoid conflicts with the themes
// so we remove all and add our own via preview-head.html
if (data.assets.css) {
data.assets.css = data.assets.css.filter(css => !css.startsWith("ui-theme-"))
}
// Tell webpack to move on
cb(null, data)
}
)
})
}
}
module.exports = RemoveThemesPlugin

View File

@@ -0,0 +1,89 @@
/*
* 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.
*/
const path = require("path");
const fs = require("fs");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const RemoveThemesPlugin = require("./RemoveThemesPlugin");
const root = path.resolve("..");
const themedir = path.join(root, "ui-styles", "src");
const themes = fs
.readdirSync(themedir)
.map((filename) => path.parse(filename))
.filter((p) => p.ext === ".scss")
.reduce((entries, current) => ({ ...entries, [`ui-theme-${current.name}`]: path.join(themedir, current.base) }), {});
module.exports = {
typescript: { reactDocgen: false },
core: {
builder: "webpack5",
},
stories: ["../src/**/*.stories.@(js|jsx|ts|tsx|mdx)"],
addons: [
"storybook-addon-i18next",
"storybook-addon-themes",
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-interactions",
"@storybook/addon-a11y",
"storybook-addon-pseudo-states",
"storybook-addon-mock",
],
framework: "@storybook/react",
webpackFinal: async (config) => {
// add our themes to webpack entry points
config.entry = {
main: config.entry,
...themes,
};
// create separate css files for our themes
config.plugins.push(
new MiniCssExtractPlugin({
filename: "[name].css",
ignoreOrder: false,
})
);
config.module.rules.push({
test: /\.scss$/,
use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"],
});
// the html-webpack-plugin adds the generated css and js files to the iframe,
// which overrides our manually loaded css files.
// So we use a custom plugin which uses a hook of html-webpack-plugin
// to filter our themes from the output.
config.plugins.push(new RemoveThemesPlugin());
// force cjs instead of esm
// https://github.com/tannerlinsley/react-query/issues/3513
config.resolve.alias["react-query/devtools"] = require.resolve("react-query/devtools");
return config;
},
};

View File

@@ -0,0 +1,26 @@
<!--
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.
-->
<link id="ui-theme" data-theme="light" rel="stylesheet" type="text/css" href="/ui-theme-light.css">

View File

@@ -0,0 +1,67 @@
/*
* 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, { useEffect } from "react";
import { initReactI18next } from "react-i18next";
import i18n from "i18next";
import { withThemes } from "storybook-addon-themes";
i18n.use(initReactI18next).init({
whitelist: ["en", "de"],
lng: "en",
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
});
const Decorator = ({ children, themeName }) => {
useEffect(() => {
const link = document.querySelector("#ui-theme");
if (link && link["data-theme"] !== themeName) {
link.href = `ui-theme-${themeName}.css`;
link["data-theme"] = themeName;
}
}, [themeName]);
return <>{children}</>;
};
export const decorators = [withThemes];
export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
themes: {
Decorator,
clearable: false,
default: "light",
list: [
{ name: "light", color: "#fff" },
{ name: "highcontrast", color: "#050514" },
{ name: "dark", color: "#121212" },
],
},
};

View File

@@ -0,0 +1,49 @@
{
"name": "@scm-manager/ui-layout",
"private": true,
"version": "2.43.1-SNAPSHOT",
"main": "build/index.js",
"types": "build/index.d.ts",
"module": "build/index.mjs",
"license": "MIT",
"scripts": {
"build": "tsup ./src/index.ts -d build --format esm,cjs --dts",
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook"
},
"devDependencies": {
"@babel/core": "^7.19.0",
"@scm-manager/eslint-config": "^2.16.0",
"@scm-manager/prettier-config": "^2.10.1",
"@scm-manager/tsconfig": "^2.13.0",
"@scm-manager/ui-styles": "2.43.1-SNAPSHOT",
"@scm-manager/ui-overlays": "2.43.1-SNAPSHOT",
"@scm-manager/ui-buttons": "2.43.1-SNAPSHOT",
"@storybook/addon-actions": "^6.5.10",
"@storybook/addon-essentials": "^6.5.10",
"@storybook/addon-interactions": "^6.5.10",
"@storybook/addon-links": "^6.5.10",
"@storybook/builder-webpack5": "^6.5.10",
"@storybook/manager-webpack5": "^6.5.10",
"@storybook/react": "^6.5.10",
"@storybook/testing-library": "^0.0.13",
"@storybook/addon-docs": "^6.5.14",
"babel-loader": "^8.2.5",
"storybook-addon-mock": "^3.2.0",
"storybook-addon-themes": "^6.1.0",
"tsup": "^6.2.3"
},
"peerDependencies": {
"react": "17",
"react-dom": "17",
"classnames": "2",
"styled-components": "5"
},
"prettier": "@scm-manager/prettier-config",
"eslintConfig": {
"extends": "@scm-manager/eslint-config"
},
"publishConfig": {
"access": "restricted"
}
}

View File

@@ -0,0 +1,57 @@
/*
* 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, { LiHTMLAttributes } from "react";
import styled from "styled-components";
import classNames from "classnames";
const CardElement = styled.li`
display: grid;
grid-template-columns: minmax(0, 1fr) min-content;
grid-template-rows: auto;
`;
const CardActionContainer = styled.span`
grid-column: -1;
grid-row: 1;
margin-top: -0.5rem;
margin-right: -0.5rem;
`;
type Props = LiHTMLAttributes<HTMLLIElement> & {
action?: React.ReactElement;
};
/**
* @beta
* @since 2.44.0
*/
const Card = React.forwardRef<HTMLLIElement, Props>(({ className, children, action, ...props }, ref) => (
<CardElement className={classNames(className, "is-relative", "p-2")} ref={ref} {...props}>
{children}
{action ? <CardActionContainer>{action}</CardActionContainer> : null}
</CardElement>
));
export default Card;

View File

@@ -0,0 +1,117 @@
/*
* 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 StoryRouter from "storybook-react-router";
import { ComponentMeta, StoryFn } from "@storybook/react";
import React, { ComponentProps } from "react";
import { ExtractProps } from "@scm-manager/ui-extensions";
import { Link } from "react-router-dom";
import CardList, { CardListBox } from "./CardList";
import Card from "./Card";
import CardTitle from "./CardTitle";
import { Menu } from "@scm-manager/ui-overlays";
import { Icon } from "@scm-manager/ui-buttons";
import CardRow from "./CardRow";
export default {
title: "CardList",
component: CardList,
decorators: [StoryRouter()],
} as ComponentMeta<typeof CardList>;
const Template: StoryFn<ExtractProps<typeof CardListBox>> = (args) => <CardListBox {...args} />;
export const Default = Template.bind({});
// More on args: https://storybook.js.org/docs/react/writing-stories/args
Default.args = {
children: [
// eslint-disable-next-line no-console
<Card>
<CardRow>
<CardTitle>My favorite repository</CardTitle>
</CardRow>
</Card>,
<Card>
<CardRow>
<CardTitle>
<Link aria-label="Edit My least liked repo" to="/cards/1">
My least liked repo
</Link>
</CardTitle>
</CardRow>
</Card>,
<Card
action={
<Menu>
<Menu.Button>
<Icon>trash</Icon>
Delete
</Menu.Button>
</Menu>
}
>
<CardRow className="is-flex is-justify-content-space-between">
<CardTitle>
<Link aria-label="Edit My other favorite repository" to="/cards/1">
My other favorite repository
</Link>
</CardTitle>
(TAG)
</CardRow>
<CardRow className="is-size-6">
This is a card description in the second row. Highlighting how the layout flows if there are multiple rows in
one card while the card also has an action.
</CardRow>
<CardRow className="is-size-6 is-flex is-justify-content-space-between">
<span>This is a third row, lets see how this works out.</span>(MERGED)
</CardRow>
</Card>,
<Card>
<CardRow className="is-flex is-align-items-center">
<CardTitle>
<Link
aria-label="Edit Enhance descriptions to differentiate between dumps with and without metadata."
to="/cards/1"
>
Enhance descriptions to differentiate between dumps with and without metadata.
</Link>
</CardTitle>
<small>#13456</small>
</CardRow>
<CardRow className="is-size-6">
Another Name requested to merge <strong>feature/asdkjertg</strong> into <strong>develop</strong> about 3 months
ago.
</CardRow>
<CardRow className="is-size-6 is-flex is-justify-content-space-between">
<div>
<span>Tasks (3/3)</span>
<span>Reviewers (1)</span>
<span>Analyses ()</span>
<span>Workflow ()</span>
</div>
<span>(OPEN)</span>
</CardRow>
</Card>,
],
} as ComponentProps<typeof CardListBox>;

View File

@@ -0,0 +1,67 @@
/*
* 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, { HTMLAttributes } from "react";
import classNames from "classnames";
import styled from "styled-components";
const CardListElement = styled.ul`
> * + * {
border-top: var(--scm-border);
margin-top: 0.5rem;
padding-top: 1rem !important;
*:is(h1, h2, h3, h4, h5, h6) a::after {
top: 0.5rem !important;
}
}
`;
type Props = HTMLAttributes<HTMLUListElement>;
/**
* The {@link CardList.Card.Title} is currently represented as a `h3`, which means the list can only be used on the top level of the page without breaking accessibility.
*
* @beta
* @since 2.44.0
*/
const CardList = React.forwardRef<HTMLUListElement, Props>(({ children, className, ...props }, ref) => (
<CardListElement ref={ref} {...props} className={classNames(className, "is-flex", "is-flex-direction-column")}>
{children}
</CardListElement>
));
/**
* The {@link CardList.Card.Title} is currently represented as a `h3`, which means the list can only be used on the top level of the page without breaking accessibility.
*
* @beta
* @since 2.44.0
*/
export const CardListBox = React.forwardRef<HTMLUListElement, Props>(({ className, children, ...props }, ref) => (
<CardList className={classNames(className, "p-2 box")} ref={ref} {...props}>
{children}
</CardList>
));
export default CardList;

View File

@@ -0,0 +1,45 @@
/*
* 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, { HTMLAttributes } from "react";
import styled from "styled-components";
import classNames from "classnames";
const CardRowElement = styled.div`
grid-column: 1 / 2;
`;
type Props = HTMLAttributes<HTMLDivElement>;
/**
* @beta
* @since 2.44.0
*/
const CardRow = React.forwardRef<HTMLDivElement, Props>(({ className, children, ...props }, ref) => (
<CardRowElement className={classNames(className)} ref={ref} {...props}>
{children}
</CardRowElement>
));
export default CardRow;

View File

@@ -0,0 +1,79 @@
/*
* 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, { HTMLAttributes } from "react";
import styled from "styled-components";
import classNames from "classnames";
const CardTitleElement = styled.h3`
a {
color: var(--scm-secondary-text);
&:focus {
outline: none;
&::after {
outline: #af3ee7 3px solid;
outline-offset: 0px;
}
}
&:hover::after {
background-color: var(--scm-hover-color-blue);
}
&::after {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
border-radius: 4px;
}
}
`;
type Props = HTMLAttributes<HTMLHeadingElement>;
/**
* A card title may contain a link as its only child which will be automatically stretched to cover the whole card area.
*
* If a card title has a link, individual card elements which should be interactive have to get the `is-relative` class.
*
* The card title (or enclosed link) content must be an accessible text and must not contain any other interactive elements.
*
* You can wrap the title in a {@link CardList.Card.Row} to introduce other elements next to the title.
*
* The title (or its enclosing row) must be the first element in a {@link CardList.Card}.
*
* @beta
* @since 2.44.0
*/
const CardTitle = React.forwardRef<HTMLHeadingElement, Props>(({ children, className, ...props }, ref) => (
<CardTitleElement className={classNames(className, "is-ellipsis-overflow")} ref={ref} {...props}>
{children}
</CardTitleElement>
));
export default CardTitle;

View File

@@ -0,0 +1,38 @@
/*
* 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 CardListComponent, { CardListBox as CardListBoxComponent } from "./card-list/CardList";
import CardRow from "./card-list/CardRow";
import Card from "./card-list/Card";
import CardTitle from "./card-list/CardTitle";
const CardListExport = {
Card: Object.assign(Card, {
Row: CardRow,
Title: CardTitle,
}),
};
export const CardList = Object.assign(CardListComponent, CardListExport);
export const CardListBox = Object.assign(CardListBoxComponent, CardListExport);

View File

@@ -0,0 +1,3 @@
{
"extends": "@scm-manager/tsconfig"
}

View File

@@ -36,7 +36,8 @@
"react-dom": "17", "react-dom": "17",
"react-router-dom": "5", "react-router-dom": "5",
"classnames": "2", "classnames": "2",
"styled-components": "5" "styled-components": "5",
"react-i18next": "11"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-tooltip": "1.0.2", "@radix-ui/react-tooltip": "1.0.2",

View File

@@ -23,7 +23,7 @@
*/ */
import MenuComponent, { MenuButton, MenuExternalLink, MenuLink } from "./menu/Menu"; import MenuComponent, { MenuButton, MenuExternalLink, MenuLink } from "./menu/Menu";
import MenuTrigger, { DEFAULT_MENU_TRIGGER } from "./menu/MenuTrigger"; import MenuTrigger, { DefaultMenuTrigger } from "./menu/MenuTrigger";
export { default as Tooltip } from "./tooltip/Tooltip"; export { default as Tooltip } from "./tooltip/Tooltip";
@@ -32,5 +32,5 @@ export const Menu = Object.assign(MenuComponent, {
Link: MenuLink, Link: MenuLink,
ExternalLink: MenuExternalLink, ExternalLink: MenuExternalLink,
Trigger: MenuTrigger, Trigger: MenuTrigger,
DEFAULT_TRIGGER: DEFAULT_MENU_TRIGGER, DefaultTrigger: DefaultMenuTrigger,
}); });

View File

@@ -25,7 +25,7 @@
import React, { AnchorHTMLAttributes, ButtonHTMLAttributes, FC } from "react"; import React, { AnchorHTMLAttributes, ButtonHTMLAttributes, FC } from "react";
import * as RadixMenu from "@radix-ui/react-dropdown-menu"; import * as RadixMenu from "@radix-ui/react-dropdown-menu";
import styled from "styled-components"; import styled from "styled-components";
import { DEFAULT_MENU_TRIGGER } from "./MenuTrigger"; import { DefaultMenuTrigger } from "./MenuTrigger";
import classNames from "classnames"; import classNames from "classnames";
import { Link as ReactRouterLink, LinkProps as ReactRouterLinkProps } from "react-router-dom"; import { Link as ReactRouterLink, LinkProps as ReactRouterLinkProps } from "react-router-dom";
@@ -116,7 +116,7 @@ type Props = {
* @since 2.44.0 * @since 2.44.0
* @see https://www.w3.org/WAI/ARIA/apg/patterns/menubar/ * @see https://www.w3.org/WAI/ARIA/apg/patterns/menubar/
*/ */
const Menu: FC<Props> = ({ children, side, className, trigger = DEFAULT_MENU_TRIGGER }) => { const Menu: FC<Props> = ({ children, side, className, trigger = <DefaultMenuTrigger /> }) => {
return ( return (
<RadixMenu.Root> <RadixMenu.Root>
{trigger} {trigger}

View File

@@ -25,7 +25,8 @@
import React, { ComponentProps } from "react"; import React, { ComponentProps } from "react";
import { Button, Icon } from "@scm-manager/ui-buttons"; import { Button, Icon } from "@scm-manager/ui-buttons";
import * as RadixMenu from "@radix-ui/react-dropdown-menu"; import * as RadixMenu from "@radix-ui/react-dropdown-menu";
import styled from "styled-components"; import { useTranslation } from "react-i18next";
import classNames from "classnames";
type Props = ComponentProps<typeof Button>; type Props = ComponentProps<typeof Button>;
@@ -41,19 +42,22 @@ const MenuTrigger = React.forwardRef<HTMLButtonElement, Props>(({ children, ...p
</RadixMenu.Trigger> </RadixMenu.Trigger>
)); ));
const StyledMenuTrigger = styled(MenuTrigger)`
padding-left: 1em;
padding-right: 1em;
`;
/** /**
* @beta * @beta
* @since 2.44.0 * @since 2.44.0
*/ */
export const DEFAULT_MENU_TRIGGER = ( export const DefaultMenuTrigger = React.forwardRef<HTMLButtonElement, Props>(({ className, ...props }, ref) => {
<StyledMenuTrigger className="is-borderless has-background-transparent has-hover-color-blue"> const [t] = useTranslation("commons");
<Icon>ellipsis-v</Icon> return (
</StyledMenuTrigger> <MenuTrigger
); aria-label={t("menu.defaultTriggerLabel")}
className={classNames(className, "is-borderless has-background-transparent has-hover-color-blue px-2")}
ref={ref}
{...props}
>
<Icon>ellipsis-v</Icon>
</MenuTrigger>
);
});
export default MenuTrigger; export default MenuTrigger;

View File

@@ -32,6 +32,7 @@
--scm-success-color: #{$success}; --scm-success-color: #{$success};
--scm-warning-color: #{$warning}; --scm-warning-color: #{$warning};
--scm-danger-color: #{$danger}; --scm-danger-color: #{$danger};
--scm-hover-color-blue: #{scale-color($blue, $alpha: -90%)};
--scm-secondary-least-color: #{$secondary-least}; --scm-secondary-least-color: #{$secondary-least};
--scm-secondary-less-color: #{$secondary-less}; --scm-secondary-less-color: #{$secondary-less};
@@ -367,6 +368,7 @@ button, .button {
padding-left: 1.5em; padding-left: 1.5em;
padding-right: 1.5em; padding-right: 1.5em;
height: 2.5rem; height: 2.5rem;
min-width: 2.5rem;
font-weight: $weight-semibold; font-weight: $weight-semibold;
&.is-primary, &.is-primary,

View File

@@ -15,6 +15,7 @@
"@scm-manager/ui-forms": "2.43.1-SNAPSHOT", "@scm-manager/ui-forms": "2.43.1-SNAPSHOT",
"@scm-manager/ui-buttons": "2.43.1-SNAPSHOT", "@scm-manager/ui-buttons": "2.43.1-SNAPSHOT",
"@scm-manager/ui-overlays": "2.43.1-SNAPSHOT", "@scm-manager/ui-overlays": "2.43.1-SNAPSHOT",
"@scm-manager/ui-layout": "2.43.1-SNAPSHOT",
"classnames": "^2.2.5", "classnames": "^2.2.5",
"history": "^4.10.1", "history": "^4.10.1",
"i18next": "21", "i18next": "21",

View File

@@ -1,4 +1,7 @@
{ {
"menu": {
"defaultTriggerLabel": "Menü"
},
"form": { "form": {
"submit": "Speichern", "submit": "Speichern",
"reset": "Leeren", "reset": "Leeren",

View File

@@ -191,9 +191,17 @@
}, },
"tags": { "tags": {
"overview": { "overview": {
"title": "Übersicht aller verfügbaren Tags", "title": "Tags",
"noTags": "Keine Tags gefunden.", "noTags": "Keine Tags gefunden.",
"created": "Erstellt" "created": "Erstellt",
"sort": {
"label": "Sortierung nach",
"option": {
"default": "Als letztes erstellt",
"name_asc": "Name A-Z",
"name_desc": "Name Z-A"
}
}
}, },
"table": { "table": {
"tags": "Tags" "tags": "Tags"

View File

@@ -1,4 +1,7 @@
{ {
"menu": {
"defaultTriggerLabel": "Menu"
},
"form": { "form": {
"submit": "Submit", "submit": "Submit",
"reset": "Clear", "reset": "Clear",

View File

@@ -191,9 +191,17 @@
}, },
"tags": { "tags": {
"overview": { "overview": {
"title": "Overview of All Tags", "title": "Tags",
"noTags": "No tags found.", "noTags": "No tags found.",
"created": "Created" "created": "Created",
"sort": {
"label": "Sort by",
"option": {
"default": "Most recently created",
"name_asc": "Name A-Z",
"name_desc": "Name Z-A"
}
}
}, },
"table": { "table": {
"tags": "Tags" "tags": "Tags"

View File

@@ -25,10 +25,13 @@ import React, { FC } from "react";
import { Link as RouterLink } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import classNames from "classnames"; import classNames from "classnames";
import { Tag, Link } from "@scm-manager/ui-types"; import { Link, Tag } from "@scm-manager/ui-types";
import { Button, DateFromNow } from "@scm-manager/ui-components"; import { DateFromNow } from "@scm-manager/ui-components";
import { useKeyboardIteratorTarget } from "@scm-manager/ui-shortcuts"; import { useKeyboardIteratorTarget } from "@scm-manager/ui-shortcuts";
import { encodePart } from "../../sources/components/content/FileLink"; import { encodePart } from "../../sources/components/content/FileLink";
import { Menu } from "@scm-manager/ui-overlays";
import { CardList } from "@scm-manager/ui-layout";
import { Icon } from "@scm-manager/ui-buttons";
type Props = { type Props = {
tag: Tag; tag: Tag;
@@ -41,26 +44,32 @@ const TagRow: FC<Props> = ({ tag, baseUrl, onDelete }) => {
const [t] = useTranslation("repos"); const [t] = useTranslation("repos");
const ref = useKeyboardIteratorTarget(); const ref = useKeyboardIteratorTarget();
let deleteButton;
if ((tag?._links?.delete as Link)?.href) {
deleteButton = (
<Button color="text" icon="trash" action={() => onDelete(tag)} title={t("tag.delete.button")} className="px-2" />
);
}
const to = `${baseUrl}/${encodePart(tag.name)}/info`; const to = `${baseUrl}/${encodePart(tag.name)}/info`;
return ( return (
<tr> <CardList.Card
<td className="is-vertical-align-middle"> key={tag.name}
<RouterLink ref={ref} to={to} title={tag.name}> action={
{tag.name} (tag?._links?.delete as Link)?.href ? (
<span className={classNames("has-text-secondary", "is-ellipsis-overflow", "ml-2", "is-size-7")}> <Menu>
{t("tags.overview.created")} <DateFromNow date={tag.date} /> <Menu.Button onSelect={() => onDelete(tag)}>
</span> <Icon>trash</Icon>
</RouterLink> {t("tag.delete.button")}
</td> </Menu.Button>
<td className="is-vertical-align-middle has-text-centered">{deleteButton}</td> </Menu>
</tr> ) : undefined
}
>
<CardList.Card.Row>
<CardList.Card.Title>
<RouterLink ref={ref} to={to}>
{tag.name}
</RouterLink>
</CardList.Card.Title>
</CardList.Card.Row>
<CardList.Card.Row className={classNames("is-size-7", "has-text-secondary")}>
{t("tags.overview.created")} <DateFromNow className="is-relative" date={tag.date} />
</CardList.Card.Row>
</CardList.Card>
); );
}; };

View File

@@ -28,6 +28,7 @@ import { useTranslation } from "react-i18next";
import TagRow from "./TagRow"; import TagRow from "./TagRow";
import { ConfirmAlert, ErrorNotification } from "@scm-manager/ui-components"; import { ConfirmAlert, ErrorNotification } from "@scm-manager/ui-components";
import { useDeleteTag } from "@scm-manager/ui-api"; import { useDeleteTag } from "@scm-manager/ui-api";
import { CardListBox } from "@scm-manager/ui-layout";
import { KeyboardIterator } from "@scm-manager/ui-shortcuts"; import { KeyboardIterator } from "@scm-manager/ui-shortcuts";
type Props = { type Props = {
@@ -90,20 +91,13 @@ const TagTable: FC<Props> = ({ repository, baseUrl, tags }) => {
/> />
) : null} ) : null}
{error ? <ErrorNotification error={error} /> : null} {error ? <ErrorNotification error={error} /> : null}
<table className="card-table table is-hoverable is-fullwidth is-word-break"> <CardListBox>
<thead> <KeyboardIterator>
<tr> {tags.map((tag) => (
<th>{t("tags.table.tags")}</th> <TagRow key={tag.name} baseUrl={baseUrl} tag={tag} onDelete={onDelete} />
</tr> ))}
</thead> </KeyboardIterator>
<tbody> </CardListBox>
<KeyboardIterator>
{tags.map((tag) => (
<TagRow key={tag.name} baseUrl={baseUrl} tag={tag} onDelete={onDelete} />
))}
</KeyboardIterator>
</tbody>
</table>
</> </>
); );
}; };

View File

@@ -22,13 +22,14 @@
* SOFTWARE. * SOFTWARE.
*/ */
import React, { FC } from "react"; import React, { FC, useMemo, useState } from "react";
import { Repository } from "@scm-manager/ui-types"; import { Repository } from "@scm-manager/ui-types";
import { ErrorNotification, Loading, Notification, Subtitle } from "@scm-manager/ui-components"; import { ErrorNotification, Loading, Notification, Subtitle } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import orderTags from "../orderTags"; import orderTags, { SORT_OPTIONS, SortOption } from "../orderTags";
import TagTable from "../components/TagTable"; import TagTable from "../components/TagTable";
import { useTags } from "@scm-manager/ui-api"; import { useTags } from "@scm-manager/ui-api";
import { Select } from "@scm-manager/ui-forms";
type Props = { type Props = {
repository: Repository; repository: Repository;
@@ -38,6 +39,8 @@ type Props = {
const TagsOverview: FC<Props> = ({ repository, baseUrl }) => { const TagsOverview: FC<Props> = ({ repository, baseUrl }) => {
const { isLoading, error, data } = useTags(repository); const { isLoading, error, data } = useTags(repository);
const [t] = useTranslation("repos"); const [t] = useTranslation("repos");
const [sort, setSort] = useState<SortOption | undefined>();
const tags = useMemo(() => orderTags(data?._embedded?.tags || [], sort), [data, sort]);
if (error) { if (error) {
return <ErrorNotification error={error} />; return <ErrorNotification error={error} />;
@@ -47,12 +50,19 @@ const TagsOverview: FC<Props> = ({ repository, baseUrl }) => {
return <Loading />; return <Loading />;
} }
const tags = data?._embedded?.tags || [];
orderTags(tags);
return ( return (
<> <>
<Subtitle subtitle={t("tags.overview.title")} /> <Subtitle subtitle={t("tags.overview.title")} />
<div className="is-flex is-align-items-center mb-3">
<label className="mr-2" htmlFor="tags-overview-sort">
{t("tags.overview.sort.label")}
</label>
<Select id="tags-overview-sort" onChange={(e) => setSort(e.target.value as SortOption)}>
{SORT_OPTIONS.map((sortOption) => (
<option value={sortOption}>{t(`tags.overview.sort.option.${sortOption}`)}</option>
))}
</Select>
</div>
{tags.length > 0 ? ( {tags.length > 0 ? (
<TagTable repository={repository} baseUrl={baseUrl} tags={tags} /> <TagTable repository={repository} baseUrl={baseUrl} tags={tags} />
) : ( ) : (

View File

@@ -49,4 +49,14 @@ describe("order tags", () => {
orderTags(tags); orderTags(tags);
expect(tags).toEqual([tag2, tag3, tag1]); expect(tags).toEqual([tag2, tag3, tag1]);
}); });
it("should order tags ascending by name", () => {
const tags = [tag1, tag2, tag3];
orderTags(tags, "name_asc");
expect(tags).toEqual([tag1, tag2, tag3]);
});
it("should order tags descending by name", () => {
const tags = [tag1, tag2, tag3];
orderTags(tags, "name_desc");
expect(tags).toEqual([tag3, tag2, tag1]);
});
}); });

View File

@@ -25,8 +25,20 @@
// sort tags by date beginning with latest first // sort tags by date beginning with latest first
import { Tag } from "@scm-manager/ui-types"; import { Tag } from "@scm-manager/ui-types";
export default (tags: Tag[]) => { export const SORT_OPTIONS = ["default", "name_asc", "name_desc"] as const;
tags.sort((a, b) => {
return new Date(b.date) - new Date(a.date); export type SortOption = typeof SORT_OPTIONS[number];
export default (tags: Tag[], sort?: SortOption) => {
return tags.sort((a, b) => {
switch (sort) {
case "name_asc":
return a.name > b.name ? 1 : -1;
case "name_desc":
return a.name > b.name ? -1 : 1;
default:
// @ts-ignore Comparing dates is a valid operation. It is unknown why typescript shows an error here.
return new Date(b.date) - new Date(a.date);
}
}); });
}; };