Skip to content

Commit

Permalink
chore: type safe icons (#17617)
Browse files Browse the repository at this point in the history
* refactor: move svg provider to root util directory

* chore: typesafe config icons

* refactor: improve types

* chore: improve comment

* chore: update ignore files

* refactor: move typedEntries to array utils

* chore: fix action panel types

* refactor: simplify naming

* chore: conversation actions type

* chore: svg provider mocks type

* chore: context menu type

* chore: remove license from non-gh file

* chore: remove comment
  • Loading branch information
PatrykBuniX committed Jun 20, 2024
1 parent 16b52e5 commit c04887a
Show file tree
Hide file tree
Showing 33 changed files with 176 additions and 82 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ module.exports = {
'src/ext/',
'src/script/localization/**/webapp*.js',
'src/worker/',
'src/script/generated/',
'*.js',
],
parserOptions: {
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
/keys
/package-lock.json
/resource
/src/script/generated
/temp
CHANGELOG.md
node_modules
Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ src/i18n/
/resource
/assets
/src/ext
/src/script/generated
CHANGELOG.md
node_modules
npm-debug.log
Expand Down
37 changes: 37 additions & 0 deletions bin/generate_icon_names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Wire
* Copyright (C) 2024 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*
*/
import fs from 'fs';

const fileLocation = 'resource/image/icon';
const fileList = fs.readdirSync(fileLocation).filter(file => file.endsWith('.svg'));

const fileContent = `
/*
* This file is generated by bin/generate_icons.ts
* To refetch all the icons and regenerate their names, run yarn configure.
*/
export const iconFileNames = [${fileList.map(name => `'${name}'`).join(', ')}] as const;
`;

const dir = 'src/script/generated';

if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
fs.writeFileSync(`${dir}/iconFileNames.ts`, fileContent);
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,8 @@
"changelog:production": "ts-node ./bin/changelog.ts production",
"changelog:staging": "ts-node ./bin/changelog.ts staging",
"changelog:rc": "ts-node ./bin/changelog.ts production master",
"configure": "copy-config",
"configure": "copy-config && yarn generate-icon-names",
"generate-icon-names": "ts-node ./bin/generate_icon_names.ts",
"deploy": "yarn build:prod && eb deploy",
"dev": "yarn start",
"docker": "node ./bin/push_docker.js",
Expand Down
3 changes: 2 additions & 1 deletion src/script/auth/component/WirelessContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ import {FormattedMessage, useIntl} from 'react-intl';

import {CloseIcon, Content, Footer, Header, Link, Small} from '@wireapp/react-ui-kit';

import {getSVG} from 'Util/SVGProvider';

import {Config} from '../../Config';
import {cookiePolicyStrings, footerStrings} from '../../strings';
import {EXTERNAL_ROUTE} from '../externalRoute';
import {getSVG} from '../util/SVGProvider';

export interface Props extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
Expand Down
2 changes: 1 addition & 1 deletion src/script/auth/page/CustomEnvironmentRedirect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {initialRootState} from '../module/reducer';
import {mockStoreFactory} from '../util/test/mockStoreFactory';
import {mountComponent} from '../util/test/TestUtil';

jest.mock('../util/SVGProvider');
jest.mock('Util/SVGProvider');
function createMockedURLSearchParams(value: string) {
return class MockedURLSearchParams extends window.URLSearchParams {
constructor() {
Expand Down
2 changes: 1 addition & 1 deletion src/script/auth/page/CustomEnvironmentRedirect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {AnyAction, Dispatch} from 'redux';
import {Runtime, UrlUtil} from '@wireapp/commons';
import {COLOR, ContainerXS, FlexBox, Text} from '@wireapp/react-ui-kit';

import {getSVG} from 'Util/SVGProvider';
import {afterRender} from 'Util/util';

import {Page} from './Page';
Expand All @@ -35,7 +36,6 @@ import {customEnvRedirectStrings} from '../../strings';
import {actionRoot} from '../module/action';
import {bindActionCreators} from '../module/reducer';
import {QUERY_KEY} from '../route';
import {getSVG} from '../util/SVGProvider';

const REDIRECT_DELAY = 5000;
const CustomEnvironmentRedirectComponent = ({doNavigate, doSendNavigationEvent}: DispatchProps) => {
Expand Down
2 changes: 1 addition & 1 deletion src/script/auth/page/Index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {ROUTE} from '../route';
import {mockStoreFactory} from '../util/test/mockStoreFactory';
import {mountComponent} from '../util/test/TestUtil';

jest.mock('../util/SVGProvider');
jest.mock('Util/SVGProvider');

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
Expand Down
3 changes: 2 additions & 1 deletion src/script/auth/page/Index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import {AnyAction, Dispatch} from 'redux';
import {UrlUtil} from '@wireapp/commons';
import {Button, ButtonVariant, ContainerXS, ErrorMessage, Text} from '@wireapp/react-ui-kit';

import {getSVG} from 'Util/SVGProvider';

import {Page} from './Page';

import {Config} from '../../Config';
Expand All @@ -36,7 +38,6 @@ import {indexStrings, logoutReasonStrings} from '../../strings';
import {bindActionCreators, RootState} from '../module/reducer';
import * as AuthSelector from '../module/selector/AuthSelector';
import {QUERY_KEY, ROUTE} from '../route';
import {getSVG} from '../util/SVGProvider';

type Props = React.HTMLProps<HTMLDivElement>;

Expand Down
2 changes: 1 addition & 1 deletion src/script/auth/page/Login.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {ROUTE} from '../route';
import {mockStoreFactory} from '../util/test/mockStoreFactory';
import {mountComponent} from '../util/test/TestUtil';

jest.mock('../util/SVGProvider');
jest.mock('Util/SVGProvider');
describe('Login', () => {
it('successfully logs in with email', async () => {
const historyPushSpy = spyOn(history, 'pushState');
Expand Down
2 changes: 1 addition & 1 deletion src/script/auth/page/PhoneLogin.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {initialRootState} from '../module/reducer';
import {ROUTE} from '../route';
import {mockStoreFactory} from '../util/test/mockStoreFactory';
import {mountComponent} from '../util/test/TestUtil';
jest.mock('../util/SVGProvider');
jest.mock('Util/SVGProvider');
const backButtonId = 'go-login';
const phoneInputId = 'enter-phone';
const countryCodeInputId = 'enter-country-code';
Expand Down
2 changes: 1 addition & 1 deletion src/script/auth/page/SetAccountType.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {Config, Configuration} from '../../Config';
import {initialRootState} from '../module/reducer';
import {mockStoreFactory} from '../util/test/mockStoreFactory';
import {mountComponent} from '../util/test/TestUtil';
jest.mock('../util/SVGProvider');
jest.mock('Util/SVGProvider');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
Navigate: function Navigate({to}: any) {
Expand Down
2 changes: 1 addition & 1 deletion src/script/auth/page/SetEmail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {initialRootState} from '../module/reducer';
import {mockStoreFactory} from '../util/test/mockStoreFactory';
import {mountComponent} from '../util/test/TestUtil';

jest.mock('../util/SVGProvider');
jest.mock('Util/SVGProvider');

const emailInputId = 'enter-email';
const verifyButtonId = 'do-verify-email';
Expand Down
2 changes: 1 addition & 1 deletion src/script/auth/page/SetHandle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {actionRoot} from '../module/action';
import {initialRootState} from '../module/reducer';
import {mockStoreFactory} from '../util/test/mockStoreFactory';
import {mountComponent} from '../util/test/TestUtil';
jest.mock('../util/SVGProvider');
jest.mock('Util/SVGProvider');

const handleInputId = 'enter-handle';
const setHandleButtonId = 'do-send-handle';
Expand Down
2 changes: 1 addition & 1 deletion src/script/auth/page/SetPassword.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {ValidationError} from '../module/action/ValidationError';
import {initialRootState} from '../module/reducer';
import {mockStoreFactory} from '../util/test/mockStoreFactory';
import {mountComponent} from '../util/test/TestUtil';
jest.mock('../util/SVGProvider');
jest.mock('Util/SVGProvider');

const passwordInputId = 'enter-password';
const setPasswordButtonId = 'do-set-password';
Expand Down
2 changes: 1 addition & 1 deletion src/script/auth/page/TeamName.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {initialRootState} from '../module/reducer';
import {initialAuthState} from '../module/reducer/authReducer';
import {mockStoreFactory} from '../util/test/mockStoreFactory';
import {mountComponent} from '../util/test/TestUtil';
jest.mock('../util/SVGProvider');
jest.mock('Util/SVGProvider');

describe('when entering a team name', () => {
describe('the submit button', () => {
Expand Down
36 changes: 0 additions & 36 deletions src/script/auth/util/SVGProvider.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/script/components/Avatar/AvatarImage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {AvatarImage} from './AvatarImage';

import {AssetRepository} from '../../assets/AssetRepository';
import {User} from '../../entity/User';
jest.mock('../../auth/util/SVGProvider');
jest.mock('Util/SVGProvider');

describe('AvatarImage', () => {
it('fetches full avatar image for large avatars', async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/script/components/Avatar/ServiceAvatar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {AVATAR_SIZE} from 'Components/Avatar';
import {ServiceAvatar} from './ServiceAvatar';

import {ServiceEntity} from '../../integration/ServiceEntity';
jest.mock('../../auth/util/SVGProvider');
jest.mock('Util/SVGProvider');

describe('ServiceAvatar', () => {
it('shows a service icon', async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/script/components/Avatar/TemporaryGuest.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {TemporaryGuestAvatar} from './TemporaryGuestAvatar';
import {User} from '../../entity/User';

import {AVATAR_SIZE, STATE} from '.';
jest.mock('../../auth/util/SVGProvider');
jest.mock('Util/SVGProvider');

describe('TemporaryGuestAvatar', () => {
it('shows expiration circle', async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/script/components/Avatar/UserAvatar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {UserAvatar} from './UserAvatar';
import {User} from '../../entity/User';

import {AVATAR_SIZE, STATE} from '.';
jest.mock('../../auth/util/SVGProvider');
jest.mock('Util/SVGProvider');

describe('UserAvatar', () => {
it('shows participant initials if no avatar is defined', async () => {
Expand Down
38 changes: 26 additions & 12 deletions src/script/components/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,30 +19,41 @@

import React from 'react';

import {getAllSVGs} from '../auth/util/SVGProvider';
import {typedEntries} from 'Util/ArrayUtil';
import {SVGIconName, getAllSVGs} from 'Util/SVGProvider';
import {PascalCase, RemoveSuffix} from 'Util/TypeUtil';

type PascalCaseIconName = PascalCase<RemoveSuffix<SVGIconName, '-icon'>>;

type IconProps = React.SVGProps<SVGSVGElement>;

type IconList = Record<string, React.FC<IconProps>>;
type IconList = Record<PascalCaseIconName, React.FC<IconProps>>;

interface NamedIconProps extends IconProps {
name: string;
name: SVGIconName;
}

const normalizeIconName = (name: string) =>
const normalizeIconName = (name: SVGIconName): PascalCaseIconName =>
name
.replace(/-icon$/, '')
.replace(/\b\w/g, found => found.toUpperCase())
.replace(/-/g, '');
.replace(/\b\w/g, (found: string) => found.toUpperCase())
.replace(/-/g, '') as PascalCaseIconName;

const createSvgComponent = (svg: HTMLElement, displayName: string): React.FC<IconProps> => {
const SVGComponent: React.FC<IconProps> = oProps => {
const viewBox = svg.getAttribute('viewBox');
if (!viewBox) {
console.error('Svg icon must have a viewBox attribute');
throw Error('Svg icon must have a viewBox attribute');
}
const regex = /0 0 (?<width>\d+) (?<height>\d+)/;
const {width, height} = regex.exec(viewBox).groups;

const match = regex.exec(viewBox);

if (!match) {
throw Error('Svg icon viewBox attribute must be in the format "0 0 width height"');
}

const {width, height} = match.groups as {width: string; height: string};

const props = {
height: oProps.height ?? height,
Expand All @@ -57,10 +68,13 @@ const createSvgComponent = (svg: HTMLElement, displayName: string): React.FC<Ico
return SVGComponent;
};

const icons = Object.entries(getAllSVGs()).reduce<IconList>((list, [key, svg]) => {
const name = normalizeIconName(key);
return Object.assign(list, {[name]: createSvgComponent(svg.documentElement, `Icon.${name}`)});
}, {});
const icons = typedEntries(getAllSVGs()).reduce<IconList>(
(list, [key, svg]) => {
const name = normalizeIconName(key);
return Object.assign(list, {[name]: createSvgComponent(svg.documentElement, `Icon.${name}`)});
},
{} as Record<PascalCaseIconName, React.FC<IconProps>>,
);

const IconComponent: React.FC<NamedIconProps> = ({name, ...props}) => {
const componentName = normalizeIconName(name);
Expand Down
2 changes: 1 addition & 1 deletion src/script/components/calling/CallingCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ const CallingCell: React.FC<CallingCellProps> = ({
const getParticipantContext = (event: React.MouseEvent<HTMLDivElement>, participant: Participant) => {
event.preventDefault();

const muteParticipant = {
const muteParticipant: ContextMenuEntry = {
click: () => callingRepository.sendModeratorMute(conversation.qualifiedId, [participant]),
icon: 'mic-off-icon',
identifier: `moderator-mute-participant`,
Expand Down
2 changes: 1 addition & 1 deletion src/script/components/calling/ParticipantMicOnIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import React from 'react';

import {keyframes} from '@emotion/react';

import {getSVG} from '../../auth/util/SVGProvider';
import {getSVG} from 'Util/SVGProvider';

const fadeAnimation = keyframes`
0% { opacity: 0.2; }
Expand Down
4 changes: 3 additions & 1 deletion src/script/components/panel/PanelActions/PanelActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@

import React from 'react';

import {SVGIconName} from 'Util/SVGProvider';

import {listCSS} from './PanelActions.styles';

import {Icon} from '../../Icon';

export interface MenuItem {
click: () => void;
icon: string;
icon: SVGIconName;
identifier: string;
label: string;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const getConversationActions = (
const getNextConversation = () => conversationRepository.getNextConversation(conversationEntity);
const userPermissions = UserPermission.generatePermissionHelpers(teamRole);

const allMenuElements = [
const allMenuElements: {item: MenuItem; condition: boolean}[] = [
{
condition: userPermissions.canCreateGroupConversation() && is1to1Action && !isServiceMode,
item: {
Expand Down
3 changes: 2 additions & 1 deletion src/script/ui/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@ import {Icon} from 'Components/Icon';
import {IgnoreOutsideClickWrapper} from 'Components/InputBar/util/clickHandlers';
import {useMessageActionsState} from 'Components/MessagesList/Message/ContentMessage/MessageActions/MessageActions.state';
import {isEnterKey, isEscapeKey, isKey, isOneOfKeys, isSpaceKey, KEY} from 'Util/KeyboardUtil';
import {SVGIconName} from 'Util/SVGProvider';

export interface ContextMenuEntry {
availability?: Availability.Type;
click?: (event?: MouseEvent) => void;
icon?: string;
icon?: SVGIconName;
identifier?: string;
isChecked?: boolean;
isDisabled?: boolean;
Expand Down
Loading

0 comments on commit c04887a

Please sign in to comment.