Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Add Support for Multiple Featured Bounties #833

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions src/pages/superadmin/header/FeatureBountyModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const FeatureBountyModal = (props: FeatureBountyProps) => {
const isMobile = useIsMobile();
const { open, close, addToast } = props;
const [loading, setLoading] = useState(false);
const [bountyUrl, setBountyUrl] = useState(bountyStore.getFeaturedBounty()?.url || '');
const [bountyUrl, setBountyUrl] = useState(bountyStore.getFeaturedBounties()[0]?.url || '');
const config = nonWidgetConfigs['workspaceusers'];

const handleAddFeaturedBounty = async () => {
Expand Down Expand Up @@ -61,10 +61,13 @@ const FeatureBountyModal = (props: FeatureBountyProps) => {
};

const handleRemoveFeaturedBounty = () => {
bountyStore.removeFeaturedBounty();
setBountyUrl('');
if (addToast) addToast('Featured bounty deleted', 'success');
close();
const bountyId = bountyStore.getBountyIdFromURL(bountyUrl);
if (bountyId) {
bountyStore.removeFeaturedBounty(bountyId);
setBountyUrl('');
if (addToast) addToast('Featured bounty deleted', 'success');
close();
}
};

return (
Expand Down
8 changes: 4 additions & 4 deletions src/people/widgetViews/WidgetSwitchViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { uiStore } from '../../store/ui';
import { colors } from '../../config/colors';
import { useStores } from '../../store';
import { widgetConfigs } from '../utils/Constants';
import { bountyStore } from '../../store/bountyStore';
import { bountyStore, FeaturedBounty } from '../../store/bountyStore';
import OfferView from './OfferView';
import WantedView from './WantedView';
import PostView from './PostView';
Expand Down Expand Up @@ -115,9 +115,9 @@ function WidgetSwitchViewer(props: any) {
const { peoplePosts, peopleBounties, peopleOffers } = main;
const { selectedWidget, onPanelClick, org_uuid } = props;

const featuredBountyIds = bountyStore.getFeaturedBounty()?.bountyId
? [bountyStore.getFeaturedBounty()?.bountyId]
: [];
const featuredBountyIds = bountyStore
.getFeaturedBounties()
.map((b: FeaturedBounty) => b.bountyId);

const sortBounties = (bounties: any[]) => {
const featured: any[] = [];
Expand Down
156 changes: 156 additions & 0 deletions src/store/__test__/bountyStore.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import sinon from 'sinon';
import { BountyStore } from '../bountyStore';

describe('BountyStore', () => {
let store: BountyStore;
let localStorageMock: { [key: string]: string };

beforeEach(() => {
localStorageMock = {};

global.localStorage = {
getItem: (key: string) => localStorageMock[key] || null,
setItem: (key: string, value: string) => {
localStorageMock[key] = value;
},
removeItem: (key: string) => {
delete localStorageMock[key];
},
clear: () => {
localStorageMock = {};
},
length: 0,
key: () => null
};

store = new BountyStore();
});

describe('constructor', () => {
it('should initialize with empty featured bounties', () => {
expect(store.featuredBounties).toEqual([]);
expect(store.maxFeaturedBounties).toBe(3);
});

it('should load existing bounties from storage', () => {
const savedBounties = [
{ bountyId: '123', url: 'https://example.com/bounty/123', addedAt: Date.now() }
];
localStorage.setItem('featuredBounties', JSON.stringify(savedBounties));

store = new BountyStore();
expect(store.getFeaturedBounties()).toEqual(savedBounties);
});

it('should migrate old storage format', async () => {
const oldBounty = { bountyId: '123', url: 'https://example.com/bounty/123' };
localStorage.setItem('featuredBounty', JSON.stringify(oldBounty));

store = new BountyStore();

expect(store.getFeaturedBounties().length).toBe(1);
expect(store.getFeaturedBounties()[0].bountyId).toBe('123');

const newStorage = localStorage.getItem('featuredBounties');
expect(newStorage).toBeTruthy();
if (newStorage) {
const parsedBounties = JSON.parse(newStorage);
expect(parsedBounties.length).toBe(1);
expect(parsedBounties[0].bountyId).toBe('123');
}
});
});

describe('getBountyIdFromURL', () => {
it('should extract bounty ID from valid URL', () => {
const url = 'https://example.com/bounty/123';
expect(store.getBountyIdFromURL(url)).toBe('123');
});

it('should return null for invalid URL', () => {
const url = 'https://example.com/invalid';
expect(store.getBountyIdFromURL(url)).toBeNull();
});
});

describe('addFeaturedBounty', () => {
it('should add new bounty', () => {
store.addFeaturedBounty('https://example.com/bounty/123', 'Test Bounty');
expect(store.getFeaturedBounties().length).toBe(1);
expect(store.hasBounty('123')).toBe(true);
});

it('should not add duplicate bounty', () => {
store.addFeaturedBounty('https://example.com/bounty/123', 'Test Bounty');
store.addFeaturedBounty('https://example.com/bounty/123', 'Test Bounty Again');
expect(store.getFeaturedBounties().length).toBe(1);
});

it('should respect maximum limit', () => {
store.addFeaturedBounty('https://example.com/bounty/123', 'Test 1');
store.addFeaturedBounty('https://example.com/bounty/124', 'Test 2');
store.addFeaturedBounty('https://example.com/bounty/125', 'Test 3');
store.addFeaturedBounty('https://example.com/bounty/126', 'Test 4');

expect(store.getFeaturedBounties().length).toBe(3);
expect(store.hasBounty('126')).toBe(true);
expect(store.hasBounty('123')).toBe(false);
});
});

describe('removeFeaturedBounty', () => {
beforeEach(() => {
localStorage.clear();
store = new BountyStore();
store.addFeaturedBounty('https://example.com/bounty/123', 'Test Bounty');
});

it('should remove existing bounty', () => {
store.removeFeaturedBounty('123');
expect(store.getFeaturedBounties().length).toBe(0);
expect(store.hasBounty('123')).toBe(false);
});

it('should handle removing non-existent bounty', () => {
store.removeFeaturedBounty('999');
expect(store.getFeaturedBounties().length).toBe(1);
});
});

describe('clearFeaturedBounties', () => {
beforeEach(() => {
store.addFeaturedBounty('https://example.com/bounty/123', 'Test 1');
store.addFeaturedBounty('https://example.com/bounty/124', 'Test 2');
});

it('should remove all bounties', () => {
store.clearFeaturedBounties();
expect(store.getFeaturedBounties().length).toBe(0);
});
});

describe('storage operations', () => {
it('should persist bounties to localStorage', () => {
store.addFeaturedBounty('https://example.com/bounty/123', 'Test Bounty');

const saved = localStorage.getItem('featuredBounties');
expect(saved).toBeTruthy();

if (saved) {
const parsedBounties = JSON.parse(saved);
expect(parsedBounties.length).toBe(1);
expect(parsedBounties[0].bountyId).toBe('123');
}
});

it('should handle storage errors gracefully', () => {
const setItemStub = sinon.stub(localStorage, 'setItem').throws(new Error('Storage error'));

expect(() => {
store.addFeaturedBounty('https://example.com/bounty/123', 'Test Bounty');
}).not.toThrow();

setItemStub.restore();
});
});
});
67 changes: 51 additions & 16 deletions src/store/bountyStore.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { makeAutoObservable } from 'mobx';

interface FeaturedBounty {
export interface FeaturedBounty {
bountyId: string;
url: string;
addedAt: number;
title?: string;
}

class BountyStore {
featuredBounty: FeaturedBounty | null = null;
export class BountyStore {
featuredBounties: FeaturedBounty[] = [];
maxFeaturedBounties = 3;

constructor() {
makeAutoObservable(this);
Expand All @@ -15,9 +18,23 @@ class BountyStore {

private loadFromStorage(): void {
try {
const saved = localStorage.getItem('featuredBounty');
const saved = localStorage.getItem('featuredBounties');
if (saved) {
this.featuredBounty = JSON.parse(saved);
this.featuredBounties = JSON.parse(saved);
return;
}

const oldSaved = localStorage.getItem('featuredBounty');
if (oldSaved) {
const oldBounty = JSON.parse(oldSaved);
this.featuredBounties = [
{
...oldBounty,
addedAt: Date.now()
}
];
this.saveToStorage();
localStorage.removeItem('featuredBounty');
}
} catch (error) {
console.error('Error loading from storage:', error);
Expand All @@ -26,7 +43,7 @@ class BountyStore {

private saveToStorage(): void {
try {
localStorage.setItem('featuredBounty', JSON.stringify(this.featuredBounty));
localStorage.setItem('featuredBounties', JSON.stringify(this.featuredBounties));
} catch (error) {
console.error('Error saving to storage:', error);
}
Expand All @@ -37,25 +54,43 @@ class BountyStore {
return match ? match[1] : null;
}

addFeaturedBounty(url: string): void {
addFeaturedBounty(url: string, title?: string): void {
const bountyId = this.getBountyIdFromURL(url);
if (bountyId) {
this.featuredBounty = { bountyId, url };
this.saveToStorage();
}
if (!bountyId || this.hasBounty(bountyId)) return;

const newBounty: FeaturedBounty = {
bountyId,
url,
title,
addedAt: Date.now()
};

this.featuredBounties = [newBounty, ...this.featuredBounties].slice(
0,
this.maxFeaturedBounties
);

this.saveToStorage();
}

removeFeaturedBounty(): void {
this.featuredBounty = null;
removeFeaturedBounty(bountyId: string): void {
this.featuredBounties = this.featuredBounties.filter(
(b: FeaturedBounty) => b.bountyId !== bountyId
);
this.saveToStorage();
}

hasBounty(bountyId: string): boolean {
return this.featuredBounty?.bountyId === bountyId;
return this.featuredBounties.some((b: FeaturedBounty) => b.bountyId === bountyId);
}

getFeaturedBounty(): FeaturedBounty | null {
return this.featuredBounty;
getFeaturedBounties(): FeaturedBounty[] {
return this.featuredBounties;
}

clearFeaturedBounties(): void {
this.featuredBounties = [];
this.saveToStorage();
}
}

Expand Down
Loading