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 { 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}>
|
||||||
|
|
|
@ -14,3 +14,5 @@ export type {
|
||||||
TagsSettings,
|
TagsSettings,
|
||||||
Settings,
|
Settings,
|
||||||
} from './utils/settings';
|
} from './utils/settings';
|
||||||
|
|
||||||
|
export type { TagColorsStorage } from './utils/services/TagColorsStorage';
|
||||||
|
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 { 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');
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -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 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>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -32,6 +32,7 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
'ShlinkWebComponentContainer',
|
'ShlinkWebComponentContainer',
|
||||||
ShlinkWebComponentContainer,
|
ShlinkWebComponentContainer,
|
||||||
'buildShlinkApiClient',
|
'buildShlinkApiClient',
|
||||||
|
'TagColorsStorage',
|
||||||
'ShlinkWebComponent',
|
'ShlinkWebComponent',
|
||||||
'ServerError',
|
'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 { 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);
|
||||||
|
|
|
@ -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</>,
|
||||||
);
|
);
|
||||||
|
|
Loading…
Reference in a new issue