+
{{template "repo/issue/card" (dict "Issue" . "Page" $)}}
{{end}}
diff --git a/package/gitea/source/tests/e2e/issue-project.test.ts b/package/gitea/source/tests/e2e/issue-project.test.ts
index 10466cd8..544bf7b3 100644
--- a/package/gitea/source/tests/e2e/issue-project.test.ts
+++ b/package/gitea/source/tests/e2e/issue-project.test.ts
@@ -28,6 +28,85 @@ test('assign issue to project and change column', async ({page}) => {
await apiDeleteRepo(page.request, user, repoName);
});
+test('refreshes project board after issue project changes in another tab', async ({page, context}) => {
+ const repoName = `e2e-project-refresh-${randomString(8)}`;
+ const user = env.GITEA_TEST_E2E_USER;
+ await login(page);
+ await apiCreateRepo(page.request, {name: repoName, autoInit: false});
+
+ try {
+ const project = await createProject(page, {
+ owner: user,
+ repo: repoName,
+ title: 'Refresh Board',
+ });
+ const issue = await apiCreateIssue(page.request, {
+ owner: user,
+ repo: repoName,
+ title: 'Refresh board issue',
+ projects: [project.id],
+ });
+
+ await page.goto(`/${user}/${repoName}/projects/${project.id}`);
+ await expect(page.locator('.issue-card')).toContainText('Refresh board issue');
+
+ await using issuePage = await context.newPage();
+ await issuePage.goto(`/${user}/${repoName}/issues/${issue.index}`);
+ await issuePage.locator('.sidebar-project-combo > .ui.dropdown').click();
+ await issuePage.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project.id}"]`).click();
+ await issuePage.locator('.issue-content-left').click();
+ await expect(issuePage.locator(`.item.sidebar-project-card:has-text("Refresh Board")`)).toBeHidden();
+
+ await page.bringToFront();
+ await expect(page.locator('.issue-card')).not.toContainText('Refresh board issue');
+ } finally {
+ await apiDeleteRepo(page.request, user, repoName);
+ }
+});
+
+test('filters and groups project cards without navigation', async ({page}) => {
+ const repoName = `e2e-project-filter-${randomString(8)}`;
+ const user = env.GITEA_TEST_E2E_USER;
+ await login(page);
+ await apiCreateRepo(page.request, {name: repoName, autoInit: false});
+
+ try {
+ const project = await createProject(page, {
+ owner: user,
+ repo: repoName,
+ title: 'Filter Board',
+ });
+ await Promise.all([
+ apiCreateIssue(page.request, {owner: user, repo: repoName, title: 'Alpha card', projects: [project.id]}),
+ apiCreateIssue(page.request, {owner: user, repo: repoName, title: 'Beta card', projects: [project.id]}),
+ ]);
+
+ await page.goto(`/${user}/${repoName}/projects/${project.id}`);
+ const boardUrl = page.url();
+ await page.locator('[data-project-filter-input]').fill('state:open title:Alpha');
+ await expect(page.locator('.issue-card')).toHaveCount(1);
+ await expect(page.locator('.issue-card')).toContainText('Alpha card');
+ await expect(page.locator('.issue-card')).not.toContainText('Beta card');
+ await expect(page).toHaveURL(`${boardUrl}?filter=state%3Aopen+title%3AAlpha`);
+
+ await page.locator('[data-project-filter-input]').fill('state:open title:Beta');
+ await expect(page.locator('.issue-card')).toHaveCount(1);
+ await expect(page.locator('.issue-card')).toContainText('Beta card');
+ await expect(page.locator('.issue-card')).not.toContainText('Alpha card');
+
+ await page.locator('[data-project-filter-clear]').click();
+ await expect(page.locator('.issue-card')).toHaveCount(2);
+ await expect(page).toHaveURL(boardUrl);
+
+ await page.locator('[data-project-group-by]').selectOption('assignee');
+ await expect(page.locator('.project-card-group-header')).toHaveText('No value');
+ await expect(page.locator('.project-card-group-cards .issue-card')).toHaveCount(2);
+ await expect(page).toHaveURL(`${boardUrl}?group_by=assignee`);
+ } finally {
+ await apiDeleteRepo(page.request, user, repoName);
+ }
+});
+
test('create a project', async ({page}) => {
const repoName = `e2e-project-repo-${Date.now()}`;
const projectTitle = 'Test Project';
diff --git a/package/gitea/source/web_src/css/features/projects.css b/package/gitea/source/web_src/css/features/projects.css
index 25cb530f..3152c39d 100644
--- a/package/gitea/source/web_src/css/features/projects.css
+++ b/package/gitea/source/web_src/css/features/projects.css
@@ -19,6 +19,50 @@
margin: 0;
}
+.project-board-controls {
+ display: flex;
+ align-items: center;
+ gap: 0.5em;
+ flex-wrap: wrap;
+ margin-bottom: 0.5em;
+}
+
+.project-filter-input {
+ flex: 1 1 18rem;
+}
+
+.project-filter-input input {
+ min-width: 0;
+}
+
+.project-group-select {
+ display: flex;
+ align-items: center;
+ gap: 0.5em;
+}
+
+.project-group-select select {
+ min-width: 10rem;
+}
+
+.project-card-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.project-card-group-header {
+ margin: 0.5rem 0 0.25rem;
+ padding-bottom: 0.25rem;
+ border-bottom: 1px solid var(--color-secondary);
+}
+
+.project-card-group-cards {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
.project-column {
flex: 0 0 auto;
display: flex;
diff --git a/package/gitea/source/web_src/js/features/repo-projects.test.ts b/package/gitea/source/web_src/js/features/repo-projects.test.ts
new file mode 100644
index 00000000..5fafaa8c
--- /dev/null
+++ b/package/gitea/source/web_src/js/features/repo-projects.test.ts
@@ -0,0 +1,48 @@
+import {matchesProjectFilter, parseProjectFilter, projectCardGroupValue} from './repo-projects.ts';
+
+const projectCard = {
+ assignees: ['alice', 'bob'],
+ author: 'carol',
+ labels: ['bug', 'needs review'],
+ milestone: 'Release 1',
+ repository: 'gitea/gitea',
+ state: 'open',
+ status: 'In Progress',
+ title: 'Fix project board',
+ type: 'pull',
+};
+
+describe('project card filters', () => {
+ test('parses quoted OR values, aliases, repeated terms, and negation', () => {
+ const parsed = parseProjectFilter('label:bug,"needs review" assignee:alice -author:dave is:pr');
+
+ expect(parsed.error).toBeUndefined();
+ expect(parsed.terms).toEqual([
+ {field: 'label', negated: false, values: ['bug', 'needs review']},
+ {field: 'assignee', negated: false, values: ['alice']},
+ {field: 'author', negated: true, values: ['dave']},
+ {field: 'type', negated: false, values: ['pull']},
+ ]);
+ expect(matchesProjectFilter(projectCard, parsed.terms)).toBe(true);
+ expect(matchesProjectFilter(projectCard, parseProjectFilter('assignee:alice assignee:bob').terms)).toBe(true);
+ });
+
+ test('reports malformed and unknown qualifiers without filtering cards', () => {
+ expect(parseProjectFilter('unknown:value').error).toEqual({kind: 'unknown', value: 'unknown'});
+ expect(parseProjectFilter('label:"unfinished').error).toEqual({kind: 'unclosed'});
+ expect(parseProjectFilter('label:').error).toEqual({kind: 'malformed', value: ''});
+ expect(parseProjectFilter('label:bug,').error).toEqual({kind: 'malformed', value: 'bug,'});
+ expect(parseProjectFilter('state:pending').error).toEqual({kind: 'malformed', value: 'pending'});
+ });
+
+ test('matches repository aliases, status, state, type, and unqualified titles', () => {
+ expect(matchesProjectFilter(projectCard, parseProjectFilter('repository:gitea/gitea status:"In Progress" state:open type:pull board').terms)).toBe(true);
+ expect(matchesProjectFilter(projectCard, parseProjectFilter('-label:bug').terms)).toBe(false);
+ });
+
+ test('uses first multi-valued attribute for a non-duplicated group', () => {
+ expect(projectCardGroupValue(projectCard, 'assignee', 'No value')).toBe('alice');
+ expect(projectCardGroupValue(projectCard, 'label', 'No value')).toBe('bug');
+ expect(projectCardGroupValue({...projectCard, labels: []}, 'label', 'No value')).toBe('No value');
+ });
+});
diff --git a/package/gitea/source/web_src/js/features/repo-projects.ts b/package/gitea/source/web_src/js/features/repo-projects.ts
index 25846f67..b54776d2 100644
--- a/package/gitea/source/web_src/js/features/repo-projects.ts
+++ b/package/gitea/source/web_src/js/features/repo-projects.ts
@@ -8,6 +8,298 @@ import {toggleFullScreen} from '../utils.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {localUserSettings} from '../modules/user-settings.ts';
+type ProjectFilterField = 'assignee' | 'author' | 'label' | 'milestone' | 'repo' | 'state' | 'status' | 'title' | 'type';
+export type ProjectGroupBy = 'none' | 'assignee' | 'label' | 'milestone' | 'repository' | 'state' | 'status' | 'type';
+
+export type ProjectFilterParseError = {
+ kind: 'malformed' | 'unclosed' | 'unknown';
+ value?: string;
+};
+
+export type ProjectFilterTerm = {
+ field: ProjectFilterField;
+ negated: boolean;
+ values: string[];
+};
+
+export type ProjectFilterParseResult = {
+ error?: ProjectFilterParseError;
+ terms: ProjectFilterTerm[];
+};
+
+export type ProjectCardMetadata = {
+ assignees: string[];
+ author: string;
+ labels: string[];
+ milestone: string;
+ repository: string;
+ state: string;
+ status: string;
+ title: string;
+ type: string;
+};
+
+type ProjectCard = {
+ element: HTMLElement;
+ list: HTMLElement;
+ metadata: ProjectCardMetadata;
+};
+
+const projectFilterAliases: Readonly
> = {
+ assignee: 'assignee', author: 'author', column: 'status', is: 'is', label: 'label', milestone: 'milestone',
+ repo: 'repo', repository: 'repo', state: 'state', status: 'status', type: 'type',
+};
+
+function tokenizeProjectFilter(query: string): {error?: ProjectFilterParseError; tokens: string[]} {
+ const tokens: string[] = [];
+ let current = '';
+ let quoted = false;
+ for (const char of query) {
+ if (char === '"') {
+ quoted = !quoted;
+ current += char;
+ } else if (/\s/.test(char) && !quoted) {
+ if (current) tokens.push(current);
+ current = '';
+ } else {
+ current += char;
+ }
+ }
+ if (quoted) return {error: {kind: 'unclosed'}, tokens: []};
+ if (current) tokens.push(current);
+ return {tokens};
+}
+
+function parseProjectFilterValues(value: string): {error?: ProjectFilterParseError; values: string[]} {
+ if (!value || value.startsWith(',') || value.endsWith(',')) return {error: {kind: 'malformed', value}, values: []};
+ const values: string[] = [];
+ let offset = 0;
+ while (offset < value.length) {
+ if (value[offset] === ',') return {error: {kind: 'malformed', value}, values: []};
+ if (value[offset] === '"') {
+ const endQuote = value.indexOf('"', offset + 1);
+ if (endQuote === -1 || endQuote === offset + 1) return {error: {kind: 'malformed', value}, values: []};
+ values.push(value.slice(offset + 1, endQuote).toLowerCase());
+ offset = endQuote + 1;
+ if (offset < value.length && value[offset] !== ',') return {error: {kind: 'malformed', value}, values: []};
+ } else {
+ const comma = value.indexOf(',', offset);
+ const end = comma === -1 ? value.length : comma;
+ const current = value.slice(offset, end).trim();
+ if (!current || current.includes('"')) return {error: {kind: 'malformed', value}, values: []};
+ values.push(current.toLowerCase());
+ offset = end;
+ }
+ if (offset < value.length) offset++;
+ }
+ return {values};
+}
+
+function normalizeProjectFilter(field: ProjectFilterField | 'is', values: string[]): {error?: ProjectFilterParseError; field?: ProjectFilterField; values?: string[]} {
+ if (field === 'is') {
+ const fields = values.map((value) => value === 'open' || value === 'closed' ? 'state' : value === 'issue' || value === 'pr' || value === 'pull' ? 'type' : undefined);
+ const normalizedField = fields[0];
+ if (!normalizedField || fields.some((value) => value !== normalizedField)) return {error: {kind: 'malformed', value: values.join(',')}};
+ return {field: normalizedField, values: values.map((value) => value === 'pr' ? 'pull' : value)};
+ }
+ if (field === 'state' && values.some((value) => value !== 'open' && value !== 'closed')) return {error: {kind: 'malformed', value: values.join(',')}};
+ if (field === 'type' && values.some((value) => value !== 'issue' && value !== 'pr' && value !== 'pull')) return {error: {kind: 'malformed', value: values.join(',')}};
+ return {field, values: values.map((value) => value === 'pr' ? 'pull' : value)};
+}
+
+export function parseProjectFilter(query: string): ProjectFilterParseResult {
+ const tokenized = tokenizeProjectFilter(query.trim());
+ if (tokenized.error) return {error: tokenized.error, terms: []};
+ const terms: ProjectFilterTerm[] = [];
+ for (const rawToken of tokenized.tokens) {
+ const negated = rawToken.startsWith('-');
+ const token = negated ? rawToken.slice(1) : rawToken;
+ if (!token) return {error: {kind: 'malformed', value: rawToken}, terms: []};
+ const separator = token.indexOf(':');
+ if (separator === -1) {
+ const values = parseProjectFilterValues(token);
+ if (values.error) return {error: values.error, terms: []};
+ terms.push({field: 'title', negated, values: values.values});
+ continue;
+ }
+ const qualifier = token.slice(0, separator).toLowerCase();
+ const field = projectFilterAliases[qualifier];
+ if (!field) return {error: {kind: 'unknown', value: qualifier}, terms: []};
+ const values = parseProjectFilterValues(token.slice(separator + 1));
+ if (values.error) return {error: values.error, terms: []};
+ const normalized = normalizeProjectFilter(field, values.values);
+ if (normalized.error) return {error: normalized.error, terms: []};
+ terms.push({field: normalized.field!, negated, values: normalized.values!});
+ }
+ return {terms};
+}
+
+function parseProjectCardValues(value: string | null): string[] {
+ if (!value) return [];
+ try {
+ const parsed: unknown = JSON.parse(value);
+ return Array.isArray(parsed) && parsed.every((item) => typeof item === 'string') ? parsed : [];
+ } catch (error) {
+ console.warn('Invalid project card metadata', error);
+ return [];
+ }
+}
+
+function projectCardMetadata(element: HTMLElement): ProjectCardMetadata {
+ return {
+ assignees: parseProjectCardValues(element.getAttribute('data-project-card-assignees')),
+ author: element.getAttribute('data-project-card-author') || '', labels: parseProjectCardValues(element.getAttribute('data-project-card-labels')),
+ milestone: element.getAttribute('data-project-card-milestone') || '', repository: element.getAttribute('data-project-card-repository') || '',
+ state: element.getAttribute('data-project-card-state') || '', status: element.getAttribute('data-project-card-status') || '',
+ title: element.getAttribute('data-project-card-title') || '', type: element.getAttribute('data-project-card-type') || '',
+ };
+}
+
+function projectCardValues(metadata: ProjectCardMetadata, field: ProjectFilterField): string[] {
+ switch (field) {
+ case 'assignee': return metadata.assignees;
+ case 'author': return [metadata.author];
+ case 'label': return metadata.labels;
+ case 'milestone': return [metadata.milestone];
+ case 'repo': return [metadata.repository];
+ case 'state': return [metadata.state];
+ case 'status': return [metadata.status];
+ case 'title': return [metadata.title];
+ case 'type': return [metadata.type];
+ }
+}
+
+export function matchesProjectFilter(metadata: ProjectCardMetadata, terms: ProjectFilterTerm[]): boolean {
+ return terms.every((term) => {
+ const matches = term.values.some((value) => projectCardValues(metadata, term.field).some((cardValue) => {
+ const normalizedCardValue = cardValue.toLowerCase();
+ if (term.field === 'title') return normalizedCardValue.includes(value);
+ if (term.field === 'repo') return normalizedCardValue === value || normalizedCardValue.endsWith(`/${value}`);
+ return normalizedCardValue === value;
+ }));
+ return term.negated ? !matches : matches;
+ });
+}
+
+function normalizeProjectGroupBy(groupBy: string): ProjectGroupBy {
+ if (groupBy === 'column') return 'status';
+ if (groupBy === 'repo') return 'repository';
+ if (groupBy === 'assignee' || groupBy === 'label' || groupBy === 'milestone' || groupBy === 'repository' || groupBy === 'state' || groupBy === 'status' || groupBy === 'type') return groupBy;
+ return 'none';
+}
+
+export function projectCardGroupValue(metadata: ProjectCardMetadata, groupBy: ProjectGroupBy, noValue: string): string {
+ // Multi-valued labels and assignees use their first loaded value so one movable card is never duplicated.
+ switch (groupBy) {
+ case 'assignee': return metadata.assignees[0] || noValue;
+ case 'label': return metadata.labels[0] || noValue;
+ case 'milestone': return metadata.milestone || noValue;
+ case 'repository': return metadata.repository || noValue;
+ case 'state': return metadata.state || noValue;
+ case 'type': return metadata.type || noValue;
+ default: return noValue;
+ }
+}
+
+function createProjectCardGroup(title: string, cards: ProjectCard[]): HTMLElement {
+ const group = document.createElement('section');
+ group.classList.add('project-card-group');
+ const header = document.createElement('h3');
+ header.classList.add('project-card-group-header');
+ header.textContent = title;
+ const groupCards = document.createElement('div');
+ groupCards.classList.add('project-card-group-cards');
+ groupCards.append(...cards.map((card) => card.element));
+ group.append(header, groupCards);
+ return group;
+}
+
+function setProjectIssueCount(list: HTMLElement, count: number): void {
+ list.closest('.project-column')!.querySelector('.project-column-issue-count')!.textContent = String(count);
+}
+
+function initRepoProjectFilters(elProjectsView: HTMLElement): void {
+ const board = elProjectsView.querySelector('#project-board')!;
+ const input = elProjectsView.querySelector('[data-project-filter-input]')!;
+ const clear = elProjectsView.querySelector('[data-project-filter-clear]')!;
+ const select = elProjectsView.querySelector('[data-project-group-by]')!;
+ const errorElement = elProjectsView.querySelector('[data-project-filter-error]')!;
+ const emptyElement = elProjectsView.querySelector('[data-project-filter-empty]')!;
+ const lists = Array.from(board.querySelectorAll('.project-column > .cards'));
+ const cardsByList = new Map();
+ let viewWasModified = false;
+
+ const snapshotCards = () => {
+ for (const list of lists) {
+ cardsByList.set(list, Array.from(list.querySelectorAll('.issue-card'), (element) => ({element, list, metadata: projectCardMetadata(element)})));
+ }
+ };
+
+ const initializeFromUrl = () => {
+ const url = new URL(window.location.href);
+ input.value = url.searchParams.get('filter') || '';
+ select.value = normalizeProjectGroupBy(url.searchParams.get('group_by') || select.value);
+ };
+ const apply = (replaceUrl: boolean) => {
+ const parsed = parseProjectFilter(input.value);
+ const groupBy = normalizeProjectGroupBy(select.value);
+ select.value = groupBy;
+ if (!viewWasModified) snapshotCards();
+ board.setAttribute('data-project-board-view-modified', String(Boolean(input.value.trim()) || (groupBy !== 'none' && groupBy !== 'status')));
+ errorElement.hidden = !parsed.error;
+ errorElement.textContent = parsed.error ? (errorElement.getAttribute(`data-project-filter-error-${parsed.error.kind}`)!.replace('%s', parsed.error.value || '')) : '';
+ let matchingCount = 0;
+ for (const list of lists) {
+ const cards = cardsByList.get(list)!;
+ for (const card of cards) {
+ card.metadata = projectCardMetadata(card.element);
+ }
+ const matchingCards = parsed.error ? cards : cards.filter((card) => matchesProjectFilter(card.metadata, parsed.terms));
+ matchingCount += matchingCards.length;
+ setProjectIssueCount(list, matchingCards.length);
+ if (groupBy === 'none' || groupBy === 'status') {
+ list.replaceChildren(...matchingCards.map((card) => card.element));
+ continue;
+ }
+ const groups = new Map();
+ for (const card of matchingCards) {
+ const title = projectCardGroupValue(card.metadata, groupBy, board.getAttribute('data-project-group-no-value')!);
+ const groupedCards = groups.get(title);
+ if (groupedCards) groupedCards.push(card);
+ else groups.set(title, [card]);
+ }
+ list.replaceChildren(...Array.from(groups, ([title, groupedCards]) => createProjectCardGroup(title, groupedCards)));
+ }
+ emptyElement.hidden = matchingCount !== 0 || Boolean(parsed.error);
+ viewWasModified = Boolean(input.value.trim()) || (groupBy !== 'none' && groupBy !== 'status');
+ if (!replaceUrl) return;
+ const url = new URL(window.location.href);
+ if (input.value.trim()) url.searchParams.set('filter', input.value);
+ else url.searchParams.delete('filter');
+ if (groupBy === 'none') url.searchParams.delete('group_by');
+ else url.searchParams.set('group_by', groupBy);
+ window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`);
+ };
+
+ initializeFromUrl();
+ apply(false);
+ input.addEventListener('input', () => apply(true));
+ select.addEventListener('change', () => apply(true));
+ clear.addEventListener('click', () => {
+ input.value = '';
+ input.focus();
+ apply(true);
+ });
+ window.addEventListener('popstate', () => {
+ initializeFromUrl();
+ apply(false);
+ });
+}
+
+function isProjectBoardViewModified(): boolean {
+ return document.querySelector('#project-board')?.getAttribute('data-project-board-view-modified') === 'true';
+}
+
function updateIssueCount(card: HTMLElement): void {
const parent = card.parentElement!;
const count = parent.querySelectorAll('.issue-card').length;
@@ -15,6 +307,14 @@ function updateIssueCount(card: HTMLElement): void {
}
async function moveIssue({item, from, to, oldIndex}: SortableEvent): Promise {
+ if (isProjectBoardViewModified()) {
+ if (oldIndex !== undefined) {
+ const siblings = Array.from(from.children).filter((child) => child !== item);
+ from.insertBefore(item, siblings[oldIndex] || null);
+ }
+ return;
+ }
+
const columnCards = to.querySelectorAll('.issue-card');
updateIssueCount(from);
updateIssueCount(to);
@@ -48,7 +348,9 @@ async function initRepoProjectSortable(): Promise {
handle: '.project-column-header',
delayOnTouchOnly: true,
delay: 500,
+ onMove: () => !isProjectBoardViewModified(),
onSort: async () => { // eslint-disable-line @typescript-eslint/no-misused-promises
+ if (isProjectBoardViewModified()) return;
boardColumns = mainBoard.querySelectorAll('.project-column');
const columnSorting = {
@@ -76,6 +378,7 @@ async function initRepoProjectSortable(): Promise {
onUpdate: moveIssue, // eslint-disable-line @typescript-eslint/no-misused-promises
delayOnTouchOnly: true,
delay: 500,
+ onMove: () => !isProjectBoardViewModified(),
});
}
}
@@ -127,6 +430,9 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
const elBoardColumn = writableProjectBoard.querySelector(`.project-column[data-id="${CSS.escape(columnId)}"]`)!;
const elBoardColumnTitle = elBoardColumn.querySelector(`.project-column-title-text`)!;
elBoardColumnTitle.textContent = elColumnTitle.value;
+ for (const card of elBoardColumn.querySelectorAll('.issue-card')) {
+ card.setAttribute('data-project-card-status', elColumnTitle.value);
+ }
if (elColumnColor.value) {
const textColor = contrastColor(elColumnColor.value);
elBoardColumn.style.setProperty('background', elColumnColor.value, 'important');
@@ -173,9 +479,35 @@ function initRepoProjectToggleFullScreen(elProjectsView: HTMLElement): void {
}
}
+function initRepoProjectRefresh(): void {
+ let wasHidden = document.hidden;
+ let isReloading = false;
+ const reloadProject = () => {
+ if (isReloading) return;
+ isReloading = true;
+ window.location.reload();
+ };
+
+ document.addEventListener('visibilitychange', () => {
+ if (document.hidden) {
+ wasHidden = true;
+ } else if (wasHidden) {
+ reloadProject();
+ }
+ });
+
+ window.addEventListener('pageshow', (event) => {
+ if (event.persisted) {
+ reloadProject();
+ }
+ });
+}
+
export function initRepoProjectsView(): void {
registerGlobalInitFunc('initRepoProjectsView', (elProjectsView) => {
+ initRepoProjectRefresh();
initRepoProjectToggleFullScreen(elProjectsView);
+ initRepoProjectFilters(elProjectsView);
const writableProjectBoard = document.querySelector('#project-board[data-project-board-writable="true"]');
if (!writableProjectBoard) return;