mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-03 14:57:22 +03:00
Delegate tag color storage to ShlinkWebComponent consuming app
This commit is contained in:
parent
9f9f3b6402
commit
89e75653d7
12 changed files with 53 additions and 65 deletions
|
@ -7,12 +7,14 @@ import type { ShlinkApiClient } from './api-contract';
|
|||
import { FeaturesProvider, useFeatures } from './utils/features';
|
||||
import type { SemVer } from './utils/helpers/version';
|
||||
import { RoutesPrefixProvider } from './utils/routesPrefix';
|
||||
import type { TagColorsStorage } from './utils/services/TagColorsStorage';
|
||||
import type { Settings } from './utils/settings';
|
||||
import { SettingsProvider } from './utils/settings';
|
||||
|
||||
type ShlinkWebComponentProps = {
|
||||
serverVersion: SemVer;
|
||||
serverVersion: SemVer; // FIXME Consider making this optional and trying to resolve it if not set
|
||||
apiClient: ShlinkApiClient;
|
||||
tagColorsStorage?: TagColorsStorage;
|
||||
routesPrefix?: string;
|
||||
settings?: Settings;
|
||||
createNotFound?: (nonPrefixedHomePath: string) => ReactNode;
|
||||
|
@ -24,7 +26,7 @@ let apiClientRef: ShlinkApiClient;
|
|||
|
||||
export const createShlinkWebComponent = (
|
||||
bottle: Bottle,
|
||||
): FC<ShlinkWebComponentProps> => ({ serverVersion, apiClient, settings, routesPrefix = '', createNotFound }) => {
|
||||
): FC<ShlinkWebComponentProps> => ({ serverVersion, apiClient, settings, routesPrefix = '', createNotFound, tagColorsStorage }) => {
|
||||
const features = useFeatures(serverVersion);
|
||||
const mainContent = useRef<ReactNode>();
|
||||
const [theStore, setStore] = useState<Store | undefined>();
|
||||
|
@ -33,6 +35,10 @@ export const createShlinkWebComponent = (
|
|||
apiClientRef = apiClient;
|
||||
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
|
||||
// depend on it
|
||||
const { container } = bottle;
|
||||
|
@ -42,7 +48,7 @@ export const createShlinkWebComponent = (
|
|||
|
||||
// Load mercure info
|
||||
store.dispatch(loadMercureInfo(settings));
|
||||
}, [apiClient]);
|
||||
}, [apiClient, tagColorsStorage]);
|
||||
|
||||
return !theStore ? <></> : (
|
||||
<ReduxStoreProvider store={theStore}>
|
||||
|
|
|
@ -14,3 +14,5 @@ export type {
|
|||
TagsSettings,
|
||||
Settings,
|
||||
} from './utils/settings';
|
||||
|
||||
export type { TagColorsStorage } from './utils/services/TagColorsStorage';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { isNil } from 'ramda';
|
||||
import { rangeOf } from '../helpers';
|
||||
import type { LocalStorage } from './LocalStorage';
|
||||
import type { TagColorsStorage } from './TagColorsStorage';
|
||||
|
||||
const HEX_COLOR_LENGTH = 6;
|
||||
const HEX_DIGITS = '0123456789ABCDEF';
|
||||
|
@ -19,8 +19,8 @@ export class ColorGenerator {
|
|||
private readonly colors: Record<string, string>;
|
||||
private readonly lights: Record<string, boolean>;
|
||||
|
||||
public constructor(private readonly storage: LocalStorage) {
|
||||
this.colors = this.storage.get<Record<string, string>>('colors') ?? {};
|
||||
public constructor(private readonly storage?: TagColorsStorage) {
|
||||
this.colors = this.storage?.getTagColors() ?? {};
|
||||
this.lights = {};
|
||||
}
|
||||
|
||||
|
@ -40,7 +40,7 @@ export class ColorGenerator {
|
|||
const normalizedKey = normalizeKey(key);
|
||||
|
||||
this.colors[normalizedKey] = color;
|
||||
this.storage.set('colors', this.colors);
|
||||
this.storage?.storeTagColors(this.colors);
|
||||
|
||||
return color;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
export type TagColorsStorage = {
|
||||
getTagColors(): Record<string, string>;
|
||||
|
||||
storeTagColors(colors: Record<string, string>): void;
|
||||
};
|
|
@ -3,7 +3,6 @@ import { useTimeoutToggle } from '../helpers/hooks';
|
|||
import { jsonToCsv } from '../helpers/json';
|
||||
import { ColorGenerator } from './ColorGenerator';
|
||||
import { ImageDownloader } from './ImageDownloader';
|
||||
import { LocalStorage } from './LocalStorage';
|
||||
import { ReportExporter } from './ReportExporter';
|
||||
|
||||
export function provideServices(bottle: Bottle) {
|
||||
|
@ -11,9 +10,7 @@ export function provideServices(bottle: Bottle) {
|
|||
bottle.constant('fetch', window.fetch.bind(window));
|
||||
bottle.service('ImageDownloader', ImageDownloader, 'fetch', 'window');
|
||||
|
||||
bottle.constant('localStorage', window.localStorage);
|
||||
bottle.service('Storage', LocalStorage, 'localStorage');
|
||||
bottle.service('ColorGenerator', ColorGenerator, 'Storage');
|
||||
bottle.service('ColorGenerator', ColorGenerator, 'TagColorsStorage');
|
||||
|
||||
bottle.constant('jsonToCsv', jsonToCsv);
|
||||
bottle.service('ReportExporter', ReportExporter, 'window', 'jsonToCsv');
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import { MAIN_COLOR } from '@shlinkio/shlink-frontend-kit';
|
||||
import { fromPartial } from '@total-typescript/shoehorn';
|
||||
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', () => {
|
||||
let colorGenerator: ColorGenerator;
|
||||
const storageMock = fromPartial<LocalStorage>({
|
||||
set: vi.fn(),
|
||||
get: vi.fn().mockImplementation(() => undefined),
|
||||
const storageMock = fromPartial<TagColorsStorage>({
|
||||
storeTagColors: vi.fn(),
|
||||
getTagColors: vi.fn().mockImplementation(() => ({})),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
@ -20,14 +20,14 @@ describe('ColorGenerator', () => {
|
|||
colorGenerator.setColorForKey('foo', color);
|
||||
|
||||
expect(colorGenerator.getColorForKey('foo')).toEqual(color);
|
||||
expect(storageMock.set).toHaveBeenCalledTimes(1);
|
||||
expect(storageMock.get).toHaveBeenCalledTimes(1);
|
||||
expect(storageMock.storeTagColors).toHaveBeenCalledTimes(1);
|
||||
expect(storageMock.getTagColors).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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(storageMock.set).toHaveBeenCalledTimes(1);
|
||||
expect(storageMock.get).toHaveBeenCalledTimes(1);
|
||||
expect(storageMock.storeTagColors).toHaveBeenCalledTimes(1);
|
||||
expect(storageMock.getTagColors).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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(storageMock.set).toHaveBeenCalledTimes(1);
|
||||
expect(storageMock.get).toHaveBeenCalledTimes(1);
|
||||
expect(storageMock.storeTagColors).toHaveBeenCalledTimes(1);
|
||||
expect(storageMock.getTagColors).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('isColorLightForKey', () => {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 { useEffect } from 'react';
|
||||
import type { ShlinkApiClientBuilder } from '../api/services/ShlinkApiClientBuilder';
|
||||
|
@ -15,6 +15,7 @@ interface ShlinkWebComponentContainerProps {
|
|||
|
||||
export const ShlinkWebComponentContainer = (
|
||||
buildShlinkApiClient: ShlinkApiClientBuilder,
|
||||
tagColorsStorage: TagColorsStorage,
|
||||
ShlinkWebComponent: ShlinkWebComponentType,
|
||||
ServerError: FC,
|
||||
) => withSelectedServer<ShlinkWebComponentContainerProps>((
|
||||
|
@ -38,6 +39,7 @@ export const ShlinkWebComponentContainer = (
|
|||
apiClient={buildShlinkApiClient(selectedServer)}
|
||||
settings={settings}
|
||||
routesPrefix={routesPrefix}
|
||||
tagColorsStorage={tagColorsStorage}
|
||||
createNotFound={(nonPrefixedHomePath) => (
|
||||
<NotFound to={`${routesPrefix}${nonPrefixedHomePath}`}>List short URLs</NotFound>
|
||||
)}
|
||||
|
|
|
@ -32,6 +32,7 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||
'ShlinkWebComponentContainer',
|
||||
ShlinkWebComponentContainer,
|
||||
'buildShlinkApiClient',
|
||||
'TagColorsStorage',
|
||||
'ShlinkWebComponent',
|
||||
'ServerError',
|
||||
);
|
||||
|
|
15
src/utils/services/TagColorsStorage.ts
Normal file
15
src/utils/services/TagColorsStorage.ts
Normal 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);
|
||||
}
|
||||
}
|
|
@ -2,10 +2,12 @@ import type Bottle from 'bottlejs';
|
|||
import { csvToJson, jsonToCsv } from '../helpers/csvjson';
|
||||
import { useTimeoutToggle } from '../helpers/hooks';
|
||||
import { LocalStorage } from './LocalStorage';
|
||||
import { TagColorsStorage } from './TagColorsStorage';
|
||||
|
||||
export const provideServices = (bottle: Bottle) => {
|
||||
bottle.constant('localStorage', window.localStorage);
|
||||
bottle.service('Storage', LocalStorage, 'localStorage');
|
||||
bottle.service('TagColorsStorage', TagColorsStorage, 'Storage');
|
||||
|
||||
bottle.constant('csvToJson', csvToJson);
|
||||
bottle.constant('jsonToCsv', jsonToCsv);
|
||||
|
|
|
@ -12,6 +12,7 @@ vi.mock('react-router-dom', async () => ({
|
|||
describe('<ShlinkWebComponentContainer />', () => {
|
||||
const ShlinkWebComponentContainer = createContainer(
|
||||
vi.fn().mockReturnValue(fromPartial({})),
|
||||
fromPartial({}),
|
||||
() => <>ShlinkWebComponent</>,
|
||||
() => <>ServerError</>,
|
||||
);
|
||||
|
|
Loading…
Reference in a new issue