Delegate tag color storage to ShlinkWebComponent consuming app

This commit is contained in:
Alejandro Celaya 2023-08-06 13:54:57 +02:00
parent 9f9f3b6402
commit 89e75653d7
12 changed files with 53 additions and 65 deletions

View file

@ -7,12 +7,14 @@ import type { ShlinkApiClient } from './api-contract';
import { FeaturesProvider, useFeatures } from './utils/features'; import { FeaturesProvider, useFeatures } from './utils/features';
import type { SemVer } from './utils/helpers/version'; import type { SemVer } from './utils/helpers/version';
import { RoutesPrefixProvider } from './utils/routesPrefix'; import { RoutesPrefixProvider } from './utils/routesPrefix';
import type { TagColorsStorage } from './utils/services/TagColorsStorage';
import type { Settings } from './utils/settings'; import type { Settings } from './utils/settings';
import { SettingsProvider } from './utils/settings'; import { SettingsProvider } from './utils/settings';
type ShlinkWebComponentProps = { type ShlinkWebComponentProps = {
serverVersion: SemVer; serverVersion: SemVer; // FIXME Consider making this optional and trying to resolve it if not set
apiClient: ShlinkApiClient; apiClient: ShlinkApiClient;
tagColorsStorage?: TagColorsStorage;
routesPrefix?: string; routesPrefix?: string;
settings?: Settings; settings?: Settings;
createNotFound?: (nonPrefixedHomePath: string) => ReactNode; createNotFound?: (nonPrefixedHomePath: string) => ReactNode;
@ -24,7 +26,7 @@ let apiClientRef: ShlinkApiClient;
export const createShlinkWebComponent = ( export const createShlinkWebComponent = (
bottle: Bottle, bottle: Bottle,
): FC<ShlinkWebComponentProps> => ({ serverVersion, apiClient, settings, routesPrefix = '', createNotFound }) => { ): FC<ShlinkWebComponentProps> => ({ serverVersion, apiClient, settings, routesPrefix = '', createNotFound, tagColorsStorage }) => {
const features = useFeatures(serverVersion); const features = useFeatures(serverVersion);
const mainContent = useRef<ReactNode>(); const mainContent = useRef<ReactNode>();
const [theStore, setStore] = useState<Store | undefined>(); const [theStore, setStore] = useState<Store | undefined>();
@ -33,6 +35,10 @@ export const createShlinkWebComponent = (
apiClientRef = apiClient; apiClientRef = apiClient;
bottle.value('apiClientFactory', () => apiClientRef); bottle.value('apiClientFactory', () => apiClientRef);
if (tagColorsStorage) {
bottle.value('TagColorsStorage', tagColorsStorage);
}
// It's important to not try to resolve services before the API client has been registered, as many other services // It's important to not try to resolve services before the API client has been registered, as many other services
// depend on it // depend on it
const { container } = bottle; const { container } = bottle;
@ -42,7 +48,7 @@ export const createShlinkWebComponent = (
// Load mercure info // Load mercure info
store.dispatch(loadMercureInfo(settings)); store.dispatch(loadMercureInfo(settings));
}, [apiClient]); }, [apiClient, tagColorsStorage]);
return !theStore ? <></> : ( return !theStore ? <></> : (
<ReduxStoreProvider store={theStore}> <ReduxStoreProvider store={theStore}>

View file

@ -14,3 +14,5 @@ export type {
TagsSettings, TagsSettings,
Settings, Settings,
} from './utils/settings'; } from './utils/settings';
export type { TagColorsStorage } from './utils/services/TagColorsStorage';

View file

@ -1,6 +1,6 @@
import { isNil } from 'ramda'; import { isNil } from 'ramda';
import { rangeOf } from '../helpers'; import { rangeOf } from '../helpers';
import type { LocalStorage } from './LocalStorage'; import type { TagColorsStorage } from './TagColorsStorage';
const HEX_COLOR_LENGTH = 6; const HEX_COLOR_LENGTH = 6;
const HEX_DIGITS = '0123456789ABCDEF'; const HEX_DIGITS = '0123456789ABCDEF';
@ -19,8 +19,8 @@ export class ColorGenerator {
private readonly colors: Record<string, string>; private readonly colors: Record<string, string>;
private readonly lights: Record<string, boolean>; private readonly lights: Record<string, boolean>;
public constructor(private readonly storage: LocalStorage) { public constructor(private readonly storage?: TagColorsStorage) {
this.colors = this.storage.get<Record<string, string>>('colors') ?? {}; this.colors = this.storage?.getTagColors() ?? {};
this.lights = {}; this.lights = {};
} }
@ -40,7 +40,7 @@ export class ColorGenerator {
const normalizedKey = normalizeKey(key); const normalizedKey = normalizeKey(key);
this.colors[normalizedKey] = color; this.colors[normalizedKey] = color;
this.storage.set('colors', this.colors); this.storage?.storeTagColors(this.colors);
return color; return color;
}; };

View file

@ -0,0 +1,5 @@
export type TagColorsStorage = {
getTagColors(): Record<string, string>;
storeTagColors(colors: Record<string, string>): void;
};

View file

@ -3,7 +3,6 @@ import { useTimeoutToggle } from '../helpers/hooks';
import { jsonToCsv } from '../helpers/json'; import { jsonToCsv } from '../helpers/json';
import { ColorGenerator } from './ColorGenerator'; import { ColorGenerator } from './ColorGenerator';
import { ImageDownloader } from './ImageDownloader'; import { ImageDownloader } from './ImageDownloader';
import { LocalStorage } from './LocalStorage';
import { ReportExporter } from './ReportExporter'; import { ReportExporter } from './ReportExporter';
export function provideServices(bottle: Bottle) { export function provideServices(bottle: Bottle) {
@ -11,9 +10,7 @@ export function provideServices(bottle: Bottle) {
bottle.constant('fetch', window.fetch.bind(window)); bottle.constant('fetch', window.fetch.bind(window));
bottle.service('ImageDownloader', ImageDownloader, 'fetch', 'window'); bottle.service('ImageDownloader', ImageDownloader, 'fetch', 'window');
bottle.constant('localStorage', window.localStorage); bottle.service('ColorGenerator', ColorGenerator, 'TagColorsStorage');
bottle.service('Storage', LocalStorage, 'localStorage');
bottle.service('ColorGenerator', ColorGenerator, 'Storage');
bottle.constant('jsonToCsv', jsonToCsv); bottle.constant('jsonToCsv', jsonToCsv);
bottle.service('ReportExporter', ReportExporter, 'window', 'jsonToCsv'); bottle.service('ReportExporter', ReportExporter, 'window', 'jsonToCsv');

View file

@ -1,13 +1,13 @@
import { MAIN_COLOR } from '@shlinkio/shlink-frontend-kit'; import { MAIN_COLOR } from '@shlinkio/shlink-frontend-kit';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { ColorGenerator } from '../../../src/utils/services/ColorGenerator'; import { ColorGenerator } from '../../../src/utils/services/ColorGenerator';
import type { LocalStorage } from '../../../src/utils/services/LocalStorage'; import type { TagColorsStorage } from '../../../src/utils/services/TagColorsStorage';
describe('ColorGenerator', () => { describe('ColorGenerator', () => {
let colorGenerator: ColorGenerator; let colorGenerator: ColorGenerator;
const storageMock = fromPartial<LocalStorage>({ const storageMock = fromPartial<TagColorsStorage>({
set: vi.fn(), storeTagColors: vi.fn(),
get: vi.fn().mockImplementation(() => undefined), getTagColors: vi.fn().mockImplementation(() => ({})),
}); });
beforeEach(() => { beforeEach(() => {
@ -20,14 +20,14 @@ describe('ColorGenerator', () => {
colorGenerator.setColorForKey('foo', color); colorGenerator.setColorForKey('foo', color);
expect(colorGenerator.getColorForKey('foo')).toEqual(color); expect(colorGenerator.getColorForKey('foo')).toEqual(color);
expect(storageMock.set).toHaveBeenCalledTimes(1); expect(storageMock.storeTagColors).toHaveBeenCalledTimes(1);
expect(storageMock.get).toHaveBeenCalledTimes(1); expect(storageMock.getTagColors).toHaveBeenCalledTimes(1);
}); });
it('generates a random color when none is available for requested key', () => { it('generates a random color when none is available for requested key', () => {
expect(colorGenerator.getColorForKey('bar')).toEqual(expect.stringMatching(/^#(?:[0-9a-fA-F]{6})$/)); expect(colorGenerator.getColorForKey('bar')).toEqual(expect.stringMatching(/^#(?:[0-9a-fA-F]{6})$/));
expect(storageMock.set).toHaveBeenCalledTimes(1); expect(storageMock.storeTagColors).toHaveBeenCalledTimes(1);
expect(storageMock.get).toHaveBeenCalledTimes(1); expect(storageMock.getTagColors).toHaveBeenCalledTimes(1);
}); });
it('trims and lower cases keys before trying to match', () => { it('trims and lower cases keys before trying to match', () => {
@ -41,8 +41,8 @@ describe('ColorGenerator', () => {
expect(colorGenerator.getColorForKey('FOO')).toEqual(color); expect(colorGenerator.getColorForKey('FOO')).toEqual(color);
expect(colorGenerator.getColorForKey('FOO ')).toEqual(color); expect(colorGenerator.getColorForKey('FOO ')).toEqual(color);
expect(colorGenerator.getColorForKey(' FoO ')).toEqual(color); expect(colorGenerator.getColorForKey(' FoO ')).toEqual(color);
expect(storageMock.set).toHaveBeenCalledTimes(1); expect(storageMock.storeTagColors).toHaveBeenCalledTimes(1);
expect(storageMock.get).toHaveBeenCalledTimes(1); expect(storageMock.getTagColors).toHaveBeenCalledTimes(1);
}); });
describe('isColorLightForKey', () => { describe('isColorLightForKey', () => {

View file

@ -1,43 +0,0 @@
import { fromPartial } from '@total-typescript/shoehorn';
import { LocalStorage } from '../../../src/utils/services/LocalStorage';
describe('LocalStorage', () => {
const getItem = vi.fn((key) => (key === 'shlink.foo' ? JSON.stringify({ foo: 'bar' }) : null));
const setItem = vi.fn();
const localStorageMock = fromPartial<Storage>({ getItem, setItem });
let storage: LocalStorage;
beforeEach(() => {
storage = new LocalStorage(localStorageMock);
});
describe('set', () => {
it('writes an stringified representation of provided value in local storage', () => {
const value = { bar: 'baz' };
storage.set('foo', value);
expect(setItem).toHaveBeenCalledTimes(1);
expect(setItem).toHaveBeenCalledWith('shlink.foo', JSON.stringify(value));
});
});
describe('get', () => {
it('fetches item from local storage', () => {
storage.get('foo');
expect(getItem).toHaveBeenCalledTimes(1);
});
it('returns parsed value when requested value is found in local storage', () => {
const value = storage.get('foo');
expect(value).toEqual({ foo: 'bar' });
});
it('returns undefined when requested value is not found in local storage', () => {
const value = storage.get('bar');
expect(value).toBeUndefined();
});
});
});

View file

@ -1,4 +1,4 @@
import type { Settings, ShlinkWebComponentType } from '@shlinkio/shlink-web-component'; import type { Settings, ShlinkWebComponentType, TagColorsStorage } from '@shlinkio/shlink-web-component';
import type { FC } from 'react'; import type { FC } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import type { ShlinkApiClientBuilder } from '../api/services/ShlinkApiClientBuilder'; import type { ShlinkApiClientBuilder } from '../api/services/ShlinkApiClientBuilder';
@ -15,6 +15,7 @@ interface ShlinkWebComponentContainerProps {
export const ShlinkWebComponentContainer = ( export const ShlinkWebComponentContainer = (
buildShlinkApiClient: ShlinkApiClientBuilder, buildShlinkApiClient: ShlinkApiClientBuilder,
tagColorsStorage: TagColorsStorage,
ShlinkWebComponent: ShlinkWebComponentType, ShlinkWebComponent: ShlinkWebComponentType,
ServerError: FC, ServerError: FC,
) => withSelectedServer<ShlinkWebComponentContainerProps>(( ) => withSelectedServer<ShlinkWebComponentContainerProps>((
@ -38,6 +39,7 @@ export const ShlinkWebComponentContainer = (
apiClient={buildShlinkApiClient(selectedServer)} apiClient={buildShlinkApiClient(selectedServer)}
settings={settings} settings={settings}
routesPrefix={routesPrefix} routesPrefix={routesPrefix}
tagColorsStorage={tagColorsStorage}
createNotFound={(nonPrefixedHomePath) => ( createNotFound={(nonPrefixedHomePath) => (
<NotFound to={`${routesPrefix}${nonPrefixedHomePath}`}>List short URLs</NotFound> <NotFound to={`${routesPrefix}${nonPrefixedHomePath}`}>List short URLs</NotFound>
)} )}

View file

@ -32,6 +32,7 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
'ShlinkWebComponentContainer', 'ShlinkWebComponentContainer',
ShlinkWebComponentContainer, ShlinkWebComponentContainer,
'buildShlinkApiClient', 'buildShlinkApiClient',
'TagColorsStorage',
'ShlinkWebComponent', 'ShlinkWebComponent',
'ServerError', 'ServerError',
); );

View file

@ -0,0 +1,15 @@
import type { TagColorsStorage as BaseTagColorsStorage } from '@shlinkio/shlink-web-component';
import type { LocalStorage } from './LocalStorage';
export class TagColorsStorage implements BaseTagColorsStorage {
constructor(private readonly storage: LocalStorage) {
}
getTagColors(): Record<string, string> {
return this.storage.get<Record<string, string>>('colors') ?? {};
}
storeTagColors(colors: Record<string, string>): void {
this.storage.set('colors', colors);
}
}

View file

@ -2,10 +2,12 @@ import type Bottle from 'bottlejs';
import { csvToJson, jsonToCsv } from '../helpers/csvjson'; import { csvToJson, jsonToCsv } from '../helpers/csvjson';
import { useTimeoutToggle } from '../helpers/hooks'; import { useTimeoutToggle } from '../helpers/hooks';
import { LocalStorage } from './LocalStorage'; import { LocalStorage } from './LocalStorage';
import { TagColorsStorage } from './TagColorsStorage';
export const provideServices = (bottle: Bottle) => { export const provideServices = (bottle: Bottle) => {
bottle.constant('localStorage', window.localStorage); bottle.constant('localStorage', window.localStorage);
bottle.service('Storage', LocalStorage, 'localStorage'); bottle.service('Storage', LocalStorage, 'localStorage');
bottle.service('TagColorsStorage', TagColorsStorage, 'Storage');
bottle.constant('csvToJson', csvToJson); bottle.constant('csvToJson', csvToJson);
bottle.constant('jsonToCsv', jsonToCsv); bottle.constant('jsonToCsv', jsonToCsv);

View file

@ -12,6 +12,7 @@ vi.mock('react-router-dom', async () => ({
describe('<ShlinkWebComponentContainer />', () => { describe('<ShlinkWebComponentContainer />', () => {
const ShlinkWebComponentContainer = createContainer( const ShlinkWebComponentContainer = createContainer(
vi.fn().mockReturnValue(fromPartial({})), vi.fn().mockReturnValue(fromPartial({})),
fromPartial({}),
() => <>ShlinkWebComponent</>, () => <>ShlinkWebComponent</>,
() => <>ServerError</>, () => <>ServerError</>,
); );