Merge pull request #2388 from automatisch/introduce-customizable-footer

feat: introduce customizable footer
This commit is contained in:
Ali BARIN
2025-03-10 13:26:11 +01:00
committed by GitHub
18 changed files with 461 additions and 8 deletions

View File

@@ -11,6 +11,15 @@ export default async (request, response) => {
const configParams = (request) => {
const {
enableFooter,
footerBackgroundColor,
footerCopyrightText,
footerDocsUrl,
footerImprintUrl,
footerLogoSvgData,
footerPrivacyPolicyUrl,
footerTextColor,
footerTosUrl,
logoSvgData,
palettePrimaryDark,
palettePrimaryLight,
@@ -19,6 +28,15 @@ const configParams = (request) => {
} = request.body;
return {
enableFooter,
footerBackgroundColor,
footerCopyrightText,
footerDocsUrl,
footerImprintUrl,
footerLogoSvgData,
footerPrivacyPolicyUrl,
footerTextColor,
footerTosUrl,
logoSvgData,
palettePrimaryDark,
palettePrimaryLight,

View File

@@ -25,6 +25,18 @@ describe('PATCH /api/v1/admin/config', () => {
const palettePrimaryMain = '#00adef';
const palettePrimaryDark = '#222222';
const palettePrimaryLight = '#f90707';
const enableFooter = true;
const footerCopyrightText = '© AB Software GmbH';
const footerBackgroundColor = '#FFFFFF';
const footerTextColor = '#000000';
const footerDocsUrl = 'https://automatisch.io/docs';
const footerTosUrl = 'https://automatisch.io/terms';
const footerPrivacyPolicyUrl = 'https://automatisch.io/privacy';
const footerImprintUrl = 'https://automatisch.io/imprint';
const footerLogoSvgData =
'<svg width="25" height="25" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 100 100"><rect width="100%" height="100%" fill="white" /><text x="10" y="40" font-family="Arial" font-size="40" fill="black">Sample Footer Logo</text></svg>';
const logoSvgData =
'<svg width="25" height="25" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 100 100"><rect width="100%" height="100%" fill="white" /><text x="10" y="40" font-family="Arial" font-size="40" fill="black">A</text></svg>';
@@ -34,6 +46,15 @@ describe('PATCH /api/v1/admin/config', () => {
palettePrimaryDark: palettePrimaryDark,
palettePrimaryLight: palettePrimaryLight,
logoSvgData: logoSvgData,
enableFooter,
footerCopyrightText,
footerBackgroundColor,
footerTextColor,
footerDocsUrl,
footerTosUrl,
footerPrivacyPolicyUrl,
footerImprintUrl,
footerLogoSvgData,
};
await updateConfig(appConfig);

View File

@@ -28,6 +28,15 @@ describe('GET /api/v1/automatisch/config', () => {
palettePrimaryMain: '#0059F7',
title: 'Sample Title',
enableTemplates: true,
enableFooter: true,
footerLogoSvgData: '<svg>Sample Footer Logo</svg>',
footerCopyrightText: '© AB Software GmbH',
footerBackgroundColor: '#FFFFFF',
footerTextColor: '#000000',
footerDocsUrl: 'https://automatisch.io/docs',
footerTosUrl: 'https://automatisch.io/terms',
footerPrivacyPolicyUrl: 'https://automatisch.io/privacy',
footerImprintUrl: 'https://automatisch.io/imprint',
});
const response = await request(app)
@@ -41,6 +50,16 @@ describe('GET /api/v1/automatisch/config', () => {
additionalDrawerLink: 'link',
additionalDrawerLinkIcon: 'icon',
additionalDrawerLinkText: 'text',
enableTemplates: true,
enableFooter: true,
footerLogoSvgData: '<svg>Sample Footer Logo</svg>',
footerCopyrightText: '© AB Software GmbH',
footerBackgroundColor: '#FFFFFF',
footerTextColor: '#000000',
footerDocsUrl: 'https://automatisch.io/docs',
footerTosUrl: 'https://automatisch.io/terms',
footerPrivacyPolicyUrl: 'https://automatisch.io/privacy',
footerImprintUrl: 'https://automatisch.io/imprint',
});
expect(response.body).toStrictEqual(expectedPayload);

View File

@@ -0,0 +1,27 @@
export async function up(knex) {
await knex.schema.table('config', (table) => {
table.boolean('enable_footer').defaultTo(false);
table.text('footer_logo_svg_data');
table.string('footer_copyright_text');
table.string('footer_background_color');
table.string('footer_text_color');
table.string('footer_docs_url');
table.string('footer_tos_url');
table.string('footer_privacy_policy_url');
table.string('footer_imprint_url');
});
}
export async function down(knex) {
await knex.schema.table('config', (table) => {
table.dropColumn('enable_footer');
table.dropColumn('footer_copyright_text');
table.dropColumn('footer_logo_svg_data');
table.dropColumn('footer_background_color');
table.dropColumn('footer_text_color');
table.dropColumn('footer_docs_url');
table.dropColumn('footer_tos_url');
table.dropColumn('footer_privacy_policy_url');
table.dropColumn('footer_imprint_url');
});
}

View File

@@ -6,12 +6,63 @@ exports[`Config model > jsonSchema should have correct validations 1`] = `
"createdAt": {
"type": "string",
},
"enableFooter": {
"type": "boolean",
},
"enableTemplates": {
"type": [
"boolean",
"null",
],
},
"footerBackgroundColor": {
"type": [
"string",
"null",
],
},
"footerCopyrightText": {
"type": [
"string",
"null",
],
},
"footerDocsUrl": {
"type": [
"string",
"null",
],
},
"footerImprintUrl": {
"type": [
"string",
"null",
],
},
"footerLogoSvgData": {
"type": [
"string",
"null",
],
},
"footerPrivacyPolicyUrl": {
"type": [
"string",
"null",
],
},
"footerTextColor": {
"type": [
"string",
"null",
],
},
"footerTosUrl": {
"type": [
"string",
"null",
],
},
"id": {
"format": "uuid",
"type": "string",

View File

@@ -16,6 +16,15 @@ class Config extends Base {
palettePrimaryMain: { type: ['string', 'null'] },
title: { type: ['string', 'null'] },
enableTemplates: { type: ['boolean', 'null'] },
enableFooter: { type: 'boolean' },
footerLogoSvgData: { type: ['string', 'null'] },
footerCopyrightText: { type: ['string', 'null'] },
footerBackgroundColor: { type: ['string', 'null'] },
footerTextColor: { type: ['string', 'null'] },
footerDocsUrl: { type: ['string', 'null'] },
footerTosUrl: { type: ['string', 'null'] },
footerPrivacyPolicyUrl: { type: ['string', 'null'] },
footerImprintUrl: { type: ['string', 'null'] },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
},

View File

@@ -15,6 +15,15 @@ const configSerializer = (config) => {
palettePrimaryLight: config.palettePrimaryLight,
installationCompleted: config.installationCompleted,
title: config.title,
enableFooter: config.enableFooter,
footerLogoSvgData: config.footerLogoSvgData,
footerCopyrightText: config.footerCopyrightText,
footerBackgroundColor: config.footerBackgroundColor,
footerTextColor: config.footerTextColor,
footerDocsUrl: config.footerDocsUrl,
footerTosUrl: config.footerTosUrl,
footerPrivacyPolicyUrl: config.footerPrivacyPolicyUrl,
footerImprintUrl: config.footerImprintUrl,
};
};

View File

@@ -24,6 +24,15 @@ describe('configSerializer', () => {
additionalDrawerLink: config.additionalDrawerLink,
additionalDrawerLinkIcon: config.additionalDrawerLinkIcon,
additionalDrawerLinkText: config.additionalDrawerLinkText,
enableFooter: config.enableFooter,
footerBackgroundColor: config.footerBackgroundColor,
footerCopyrightText: config.footerCopyrightText,
footerDocsUrl: config.footerDocsUrl,
footerImprintUrl: config.footerImprintUrl,
footerLogoSvgData: config.footerLogoSvgData,
footerPrivacyPolicyUrl: config.footerPrivacyPolicyUrl,
footerTextColor: config.footerTextColor,
footerTosUrl: config.footerTosUrl,
createdAt: config.createdAt.getTime(),
updatedAt: config.updatedAt.getTime(),
};

View File

@@ -16,6 +16,15 @@ const configMock = (config) => {
installationCompleted: config.installationCompleted || false,
title: config.title,
enableTemplates: config.enableTemplates,
enableFooter: config.enableFooter || false,
footerLogoSvgData: config.footerLogoSvgData,
footerCopyrightText: config.footerCopyrightText,
footerBackgroundColor: config.footerBackgroundColor,
footerTextColor: config.footerTextColor,
footerDocsUrl: config.footerDocsUrl,
footerTosUrl: config.footerTosUrl,
footerPrivacyPolicyUrl: config.footerPrivacyPolicyUrl,
footerImprintUrl: config.footerImprintUrl,
},
meta: {
count: 1,

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
@@ -25,6 +25,7 @@
crossorigin
type="font/ttf"
/>
<link
rel="preload"
href="/fonts/Inter-Medium.ttf"
@@ -32,6 +33,7 @@
crossorigin
type="font/ttf"
/>
<link
rel="preload"
href="/fonts/Inter-Bold.ttf"
@@ -39,6 +41,7 @@
crossorigin
type="font/ttf"
/>
<style>
@font-face {
font-family: 'Inter';
@@ -63,6 +66,10 @@
font-style: normal;
font-display: swap;
}
.tsqd-parent-container .tsqd-open-btn-container {
transform: translateY(-40px);
}
</style>
</head>
<body>

View File

@@ -5,13 +5,13 @@ import Typography from '@mui/material/Typography';
import useFormatMessage from 'hooks/useFormatMessage';
import useVersion from 'hooks/useVersion';
const Footer = () => {
const AdminSettingsLayoutFooter = () => {
const version = useVersion();
const formatMessage = useFormatMessage();
return (
typeof version?.version === 'string' && (
<Box mt="auto" position="sticky" bottom={0}>
<Box mt="auto" position="sticky" bottom={0} zIndex={99}>
<Box bgcolor="common.white" mt={4}>
<Divider />
<Typography
@@ -32,4 +32,4 @@ const Footer = () => {
);
};
export default Footer;
export default AdminSettingsLayoutFooter;

View File

@@ -1,4 +1,5 @@
import styled from '@emotion/styled';
export const LogoImage = styled('img')(() => ({
maxWidth: 200,
maxHeight: 22,

View File

@@ -0,0 +1,126 @@
import styled from '@emotion/styled';
import Box from '@mui/material/Box';
import Divider from '@mui/material/Divider';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import SvgIcon from '@mui/material/SvgIcon';
import Typography from '@mui/material/Typography';
import useAutomatischConfig from 'hooks/useAutomatischConfig';
import useFormatMessage from 'hooks/useFormatMessage';
import useVersion from 'hooks/useVersion';
const LogoImage = styled('img')(() => ({
maxWidth: 'auto',
height: 22,
}));
const LayoutFooter = () => {
const { data: config, isPending: isConfigPending } = useAutomatischConfig();
const formatMessage = useFormatMessage();
const links = [
{
key: 'docs',
show: !!config.data.footerDocsUrl,
href: config.data.footerDocsUrl,
text: formatMessage('footer.docsLinkText'),
},
{
key: 'terms-of-services',
show: !!config.data.footerTosUrl,
href: config.data.footerTosUrl,
text: formatMessage('footer.tosLinkText'),
},
{
key: 'privacy-policy',
show: !!config.data.footerPrivacyPolicyUrl,
href: config.data.footerPrivacyPolicyUrl,
text: formatMessage('footer.privacyPolicyLinkText'),
},
{
key: 'imprint',
show: !!config.data.footerImprintUrl,
href: config.data.footerImprintUrl,
text: formatMessage('footer.imprintLinkText'),
},
,
];
if (config.data.enableFooter !== true) return null;
return (
<Box mt="auto" position="sticky" bottom={0}>
<Box bgcolor="common.white" mt={4}>
<Divider />
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
gap: 1,
justifyContent: 'space-between',
alignItems: 'center',
padding: '10px 20px',
bgcolor: (theme) => theme.palette.footer.main,
color: (theme) => theme.palette.footer.text,
}}
>
<div>
{config.data.footerLogoSvgData && (
<>
<LogoImage
data-test="footer-logo"
src={`data:image/svg+xml;utf8,${encodeURIComponent(config.data.footerLogoSvgData)}`}
/>
</>
)}
</div>
<div>
{config.data.footerCopyrightText && (
<Typography
variant="body1"
sx={{ flexGrow: 1, textAlign: 'center' }}
>
{config.data.footerCopyrightText}
</Typography>
)}
</div>
<Box
sx={{
display: 'flex',
gap: '15px',
flexWrap: { xs: 'wrap', sm: 'unset' },
justifyContent: 'center',
}}
>
{links
.filter((link) => link.show)
.map((link) => (
<Link
key={link.key}
href={link.href}
color="inherit"
variant="body1"
target="_blank"
rel="noreferrer noopener"
sx={{
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
{link.text}
</Link>
))}
</Box>
</Box>
</Box>
</Box>
);
};
export default LayoutFooter;

View File

@@ -19,6 +19,8 @@ import AppBar from 'components/AppBar';
import Drawer from 'components/Drawer';
import useAutomatischConfig from 'hooks/useAutomatischConfig';
import Footer from './Footer';
const additionalDrawerLinkIcons = {
Security: SecurityIcon,
ArrowBackIosNew: ArrowBackIosNewIcon,
@@ -141,6 +143,7 @@ function PublicLayout({ children }) {
<Stack flex={1}>
<Toolbar />
{children}
<Footer />
</Stack>
</Box>
</>

View File

@@ -37,6 +37,18 @@ const customizeTheme = (theme, config) => {
config.palettePrimaryDark,
);
overrideIfGiven(
shallowDefaultTheme,
'palette.footer.main',
config.footerBackgroundColor,
);
overrideIfGiven(
shallowDefaultTheme,
'palette.footer.text',
config.footerTextColor,
);
return shallowDefaultTheme;
};

View File

@@ -273,12 +273,23 @@
"permissionSettings.title": "Conditions",
"appOAuthClientsDialog.title": "Choose your authentication client",
"userInterfacePage.title": "User Interface",
"userInterfacePage.generalTitle": "General",
"userInterfacePage.footerTitle": "Footer",
"userInterfacePage.successfullyUpdated": "User interface has been updated.",
"userInterfacePage.titleFieldLabel": "Title",
"userInterfacePage.primaryMainColorFieldLabel": "Primary main color",
"userInterfacePage.primaryDarkColorFieldLabel": "Primary dark color",
"userInterfacePage.primaryLightColorFieldLabel": "Primary light color",
"userInterfacePage.svgDataFieldLabel": "Logo SVG code",
"userInterfacePage.footerLogoSvgDataFieldLabel": "Footer logo SVG code",
"userInterfacePage.footerCopyrightTextFieldLabel": "Copyright text",
"userInterfacePage.enableFooterLabel": "Enable footer",
"userInterfacePage.footerTextColorLabel": "Text color",
"userInterfacePage.footerBackgroundColorLabel": "Background color",
"userInterfacePage.footerDocsUrlLabel": "Documentation link",
"userInterfacePage.footerTosUrlLabel": "Terms of services link",
"userInterfacePage.footerPrivacyPolicyUrlLabel": "Privacy policy link",
"userInterfacePage.footerImprintUrlLabel": "Imprint link",
"userInterfacePage.submit": "Update",
"authenticationPage.title": "Single Sign-On with SAML",
"authenticationForm.active": "Active",
@@ -355,5 +366,9 @@
"flowFolderChangeDialog.confirm": "Move",
"flowFolderChangeDialog.uncategorizedFolder": "Uncategorized",
"flowFolderChangeDialog.successfullyUpdatedFolder": "The flow has been successfully moved to the new folder!",
"flowFolder.uncategorized": "Uncategorized"
"flowFolder.uncategorized": "Uncategorized",
"footer.docsLinkText": "Documentation",
"footer.tosLinkText": "Terms of Service",
"footer.privacyPolicyLinkText": "Privacy",
"footer.imprintLinkText": "Imprint"
}

View File

@@ -1,10 +1,12 @@
import LoadingButton from '@mui/lab/LoadingButton';
import Grid from '@mui/material/Grid';
import Typography from '@mui/material/Typography';
import Skeleton from '@mui/material/Skeleton';
import Stack from '@mui/material/Stack';
import mergeWith from 'lodash/mergeWith';
import * as React from 'react';
import Switch from 'components/Switch';
import ColorInput from 'components/ColorInput';
import Container from 'components/Container';
import Form from 'components/Form';
@@ -29,6 +31,16 @@ const defaultValues = {
palettePrimaryMain: primaryMainColor,
palettePrimaryDark: primaryDarkColor,
palettePrimaryLight: primaryLightColor,
logoSvgData: '',
enableFooter: false,
footerLogoSvgData: '',
footerCopyrightText: '',
footerBackgroundColor: '#FFFFFF',
footerTextColor: '#000000',
footerDocsUrl: '',
footerTosUrl: '',
footerPrivacyPolicyUrl: '',
footerImprintUrl: '',
};
const mergeIfGiven = (oldValue, newValue) => {
@@ -51,13 +63,24 @@ export default function UserInterface() {
const handleUserInterfaceUpdate = async (uiData) => {
try {
const input = {
title: uiData.title,
palettePrimaryMain: getPrimaryMainColor(uiData.palettePrimaryMain),
enableFooter: uiData.enableFooter,
footerBackgroundColor: uiData.footerBackgroundColor,
footerCopyrightText: uiData.footerCopyrightText,
footerDocsUrl: uiData.footerDocsUrl,
footerImprintUrl: uiData.footerImprintUrl,
footerLogoSvgData: uiData.footerLogoSvgData,
footerPrivacyPolicyUrl: uiData.footerPrivacyPolicyUrl,
footerTextColor: uiData.footerTextColor,
footerTosUrl: uiData.footerTosUrl,
logoSvgData: uiData.logoSvgData,
palettePrimaryDark: getPrimaryDarkColor(uiData.palettePrimaryDark),
palettePrimaryLight: getPrimaryLightColor(uiData.palettePrimaryLight),
logoSvgData: uiData.logoSvgData,
palettePrimaryMain: getPrimaryMainColor(uiData.palettePrimaryMain),
title: uiData.title,
};
await updateConfig(input);
enqueueSnackbar(formatMessage('userInterfacePage.successfullyUpdated'), {
variant: 'success',
SnackbarProps: {
@@ -92,6 +115,10 @@ export default function UserInterface() {
defaultValues={configWithDefaults}
>
<Stack direction="column" gap={2}>
<Typography variant="h4" gutterBottom={true}>
{formatMessage('userInterfacePage.generalTitle')}
</Typography>
<TextField
name="title"
label={formatMessage('userInterfacePage.titleFieldLabel')}
@@ -133,6 +160,89 @@ export default function UserInterface() {
data-test="logo-svg-data-text-field"
/>
<Typography variant="h4" gutterBottom={true} mt={3}>
{formatMessage('userInterfacePage.footerTitle')}
</Typography>
<Switch
name="enableFooter"
label={formatMessage('userInterfacePage.enableFooterLabel')}
/>
<TextField
name="footerLogoSvgData"
label={formatMessage(
'userInterfacePage.footerLogoSvgDataFieldLabel',
)}
multiline
fullWidth
data-test="footer-logo-svg-data-text-field"
/>
<TextField
name="footerCopyrightText"
label={formatMessage(
'userInterfacePage.footerCopyrightTextFieldLabel',
)}
multiline
fullWidth
data-test="footer-copyright-text-field"
/>
<ColorInput
name="footerBackgroundColor"
label={formatMessage(
'userInterfacePage.footerBackgroundColorLabel',
)}
fullWidth
data-test="footer-background-color-input"
/>
<ColorInput
name="footerTextColor"
label={formatMessage(
'userInterfacePage.footerTextColorLabel',
)}
fullWidth
data-test="footer-text-color-input"
/>
<TextField
name="footerDocsUrl"
label={formatMessage('userInterfacePage.footerDocsUrlLabel')}
multiline
fullWidth
data-test="logo-docs-text-field"
/>
<TextField
name="footerTosUrl"
label={formatMessage('userInterfacePage.footerTosUrlLabel')}
multiline
fullWidth
data-test="logo-tos-url-text-field"
/>
<TextField
name="footerPrivacyPolicyUrl"
label={formatMessage(
'userInterfacePage.footerPrivacyPolicyUrlLabel',
)}
multiline
fullWidth
data-test="logo-privacy-policy-url-text-field"
/>
<TextField
name="footerImprintUrl"
label={formatMessage(
'userInterfacePage.footerImprintUrlLabel',
)}
multiline
fullWidth
data-test="logo-imprint-url-text-field"
/>
<LoadingButton
type="submit"
variant="contained"

View File

@@ -1,10 +1,13 @@
import { deepmerge } from '@mui/utils';
import { createTheme, alpha } from '@mui/material/styles';
import { cardActionAreaClasses } from '@mui/material/CardActionArea';
const referenceTheme = createTheme();
export const primaryMainColor = '#0059F7';
export const primaryLightColor = '#4286FF';
export const primaryDarkColor = '#001F52';
export const defaultTheme = createTheme({
palette: {
primary: {
@@ -57,6 +60,10 @@ export const defaultTheme = createTheme({
paper: '#fff',
default: '#FAFAFA',
},
footer: {
main: '#FFFFFF',
text: '#001F52',
},
},
shape: {
borderRadius: 4,