Remove htmx (#37224)
Close #35059 Slightly improved the "fetch action" framework and started adding tests for it. --------- Signed-off-by: silverwind <me@silverwind.io> Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
co-authored by
GitHub
silverwind
parent
17f62bfec5
commit
2644bb8490
+1
-2
@@ -574,7 +574,6 @@ export default defineConfig([
|
|||||||
'no-restricted-properties': [2, ...restrictedProperties],
|
'no-restricted-properties': [2, ...restrictedProperties],
|
||||||
'no-restricted-imports': [2, {paths: [
|
'no-restricted-imports': [2, {paths: [
|
||||||
{name: 'jquery', message: 'Use the global $ instead', allowTypeImports: true},
|
{name: 'jquery', message: 'Use the global $ instead', allowTypeImports: true},
|
||||||
{name: 'htmx.org', message: 'Use the global htmx instead', allowTypeImports: true},
|
|
||||||
]}],
|
]}],
|
||||||
'no-restricted-syntax': [2, 'WithStatement', 'ForInStatement', 'LabeledStatement', 'SequenceExpression'],
|
'no-restricted-syntax': [2, 'WithStatement', 'ForInStatement', 'LabeledStatement', 'SequenceExpression'],
|
||||||
'no-return-assign': [0],
|
'no-return-assign': [0],
|
||||||
@@ -1021,6 +1020,6 @@ export default defineConfig([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['web_src/**/*'],
|
files: ['web_src/**/*'],
|
||||||
languageOptions: {globals: {...globals.browser, ...globals.jquery, htmx: false}},
|
languageOptions: {globals: {...globals.browser, ...globals.jquery}},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -49,7 +49,6 @@
|
|||||||
"dropzone": "6.0.0-beta.2",
|
"dropzone": "6.0.0-beta.2",
|
||||||
"easymde": "2.20.0",
|
"easymde": "2.20.0",
|
||||||
"esbuild": "0.28.0",
|
"esbuild": "0.28.0",
|
||||||
"htmx.org": "2.0.8",
|
|
||||||
"idiomorph": "0.7.4",
|
"idiomorph": "0.7.4",
|
||||||
"jquery": "4.0.0",
|
"jquery": "4.0.0",
|
||||||
"js-yaml": "4.1.1",
|
"js-yaml": "4.1.1",
|
||||||
|
|||||||
Generated
-8
@@ -157,9 +157,6 @@ importers:
|
|||||||
esbuild:
|
esbuild:
|
||||||
specifier: 0.28.0
|
specifier: 0.28.0
|
||||||
version: 0.28.0
|
version: 0.28.0
|
||||||
htmx.org:
|
|
||||||
specifier: 2.0.8
|
|
||||||
version: 2.0.8
|
|
||||||
idiomorph:
|
idiomorph:
|
||||||
specifier: 0.7.4
|
specifier: 0.7.4
|
||||||
version: 0.7.4
|
version: 0.7.4
|
||||||
@@ -2768,9 +2765,6 @@ packages:
|
|||||||
htmlparser2@8.0.2:
|
htmlparser2@8.0.2:
|
||||||
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
|
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
|
||||||
|
|
||||||
htmx.org@2.0.8:
|
|
||||||
resolution: {integrity: sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==}
|
|
||||||
|
|
||||||
iconv-lite@0.6.3:
|
iconv-lite@0.6.3:
|
||||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -6789,8 +6783,6 @@ snapshots:
|
|||||||
domutils: 3.2.2
|
domutils: 3.2.2
|
||||||
entities: 4.5.0
|
entities: 4.5.0
|
||||||
|
|
||||||
htmx.org@2.0.8: {}
|
|
||||||
|
|
||||||
iconv-lite@0.6.3:
|
iconv-lite@0.6.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: '@nolyfill/safer-buffer@1.0.44'
|
safer-buffer: '@nolyfill/safer-buffer@1.0.44'
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ func RenderMarkup(ctx *context.Base, ctxRepo *context.Repository, mode, text, ur
|
|||||||
// filePath is the path of the file to render if the end user is trying to preview a repo file (mode == "file")
|
// filePath is the path of the file to render if the end user is trying to preview a repo file (mode == "file")
|
||||||
// filePath will be used as RenderContext.RelativePath
|
// filePath will be used as RenderContext.RelativePath
|
||||||
|
|
||||||
|
// TODO: MARKUP-RENDER-CONTEXT: this logic is unnecessarily complicated.
|
||||||
|
// Ideally: the "file path" should not appear in the "url path context", but it needs a lot of refactoring to achieve that
|
||||||
// for example, when previewing file "/gitea/owner/repo/src/branch/features/feat-123/doc/CHANGE.md", then filePath is "doc/CHANGE.md"
|
// for example, when previewing file "/gitea/owner/repo/src/branch/features/feat-123/doc/CHANGE.md", then filePath is "doc/CHANGE.md"
|
||||||
// and the urlPathContext is "/gitea/owner/repo/src/branch/features/feat-123/doc"
|
// and the urlPathContext is "/gitea/owner/repo/src/branch/features/feat-123/doc"
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,12 @@
|
|||||||
<div class="ui field {{if .Required}}required{{end}}">
|
<div class="ui field {{if .Required}}required{{end}}">
|
||||||
{{if eq .Type "choice"}}
|
{{if eq .Type "choice"}}
|
||||||
<label>{{or .Description .Name}}:</label>
|
<label>{{or .Description .Name}}:</label>
|
||||||
{{/* htmx won't initialize the fomantic dropdown, so it is a standard "select" input */}}
|
|
||||||
<select class="ui selection dropdown" name="{{.Name}}">
|
<select class="ui selection dropdown" name="{{.Name}}">
|
||||||
{{range .Options}}
|
{{range .Options}}
|
||||||
<option value="{{.}}" {{if eq $item.Default .}}selected{{end}}>{{.}}</option>
|
<option value="{{.}}" {{if eq $item.Default .}}selected{{end}}>{{.}}</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
{{else if eq .Type "boolean"}}
|
{{else if eq .Type "boolean"}}
|
||||||
{{/* htmx doesn't trigger our JS code to attach fomantic label to checkbox, so here we use standard checkbox */}}
|
|
||||||
<label class="tw-flex flex-text-inline">
|
<label class="tw-flex flex-text-inline">
|
||||||
<input type="checkbox" name="{{.Name}}" {{if eq .Default "true"}}checked{{end}}>
|
<input type="checkbox" name="{{.Name}}" {{if eq .Default "true"}}checked{{end}}>
|
||||||
{{or .Description .Name}}
|
{{or .Description .Name}}
|
||||||
|
|||||||
@@ -19,13 +19,11 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="ui top attached header">
|
<div class="ui top attached header">
|
||||||
<div class="flex-text-block tw-justify-between tw-flex-wrap">
|
<div class="flex-text-block tw-justify-between tw-flex-wrap">
|
||||||
<div class="ui compact small menu small-menu-items repo-editor-menu tw-self-start">
|
<div class="ui compact small menu small-menu-items repo-editor-menu" data-repo-link="{{.RepoLink}}" data-ref-sub-url="{{.RefTypeNameSubURL}}" data-branch-name="{{.BranchName}}">
|
||||||
<a class="active item" data-tab="write">{{svg "octicon-code"}} {{if .IsNewFile}}{{ctx.Locale.Tr "repo.editor.new_file"}}{{else}}{{ctx.Locale.Tr "repo.editor.edit_file"}}{{end}}</a>
|
<a class="active item" data-tab="write">{{svg "octicon-code"}} {{if .IsNewFile}}{{ctx.Locale.Tr "repo.editor.new_file"}}{{else}}{{ctx.Locale.Tr "repo.editor.edit_file"}}{{end}}</a>
|
||||||
<a class="item{{if not .CodeEditorConfig.Previewable}} tw-hidden{{end}}" data-tab="preview" data-preview-url="{{.Repository.Link}}/markup" data-preview-context-ref="{{.RepoLink}}/src/{{.RefTypeNameSubURL}}">{{svg "octicon-eye"}} {{ctx.Locale.Tr "preview"}}</a>
|
<a class="item {{if not .CodeEditorConfig.Previewable}}tw-hidden{{end}}" data-tab="preview">{{svg "octicon-eye"}} {{ctx.Locale.Tr "preview"}}</a>
|
||||||
{{if not .IsNewFile}}
|
{{if not .IsNewFile}}
|
||||||
{{/*FIXME: the related logic is totally a mess, need to completely rewrite, that's also the root reason for
|
<a class="item" data-tab="diff">{{svg "octicon-diff"}} {{ctx.Locale.Tr "repo.editor.preview_changes"}}</a>
|
||||||
why the "migrate to CodeMirror" PR took very long time on the legacy code and introduced "#file-name (filenameInput)" regressions many times*/}}
|
|
||||||
<a class="item" data-tab="diff" hx-params="context,content" hx-vals='{"context":"{{.BranchLink}}"}' hx-include="#edit_area" hx-swap="innerHTML" hx-target=".tab[data-tab='diff']" hx-indicator=".tab[data-tab='diff']" hx-post="{{.RepoLink}}/_preview/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">{{svg "octicon-diff"}} {{ctx.Locale.Tr "repo.editor.preview_changes"}}</a>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{template "repo/editor/options" dict "CodeEditorConfig" $.CodeEditorConfig}}
|
{{template "repo/editor/options" dict "CodeEditorConfig" $.CodeEditorConfig}}
|
||||||
@@ -39,10 +37,10 @@
|
|||||||
<div class="editor-loading is-loading"></div>
|
<div class="editor-loading is-loading"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui tab tw-px-4 tw-py-3" data-tab="preview">
|
<div class="ui tab tw-px-4 tw-py-3" data-tab="preview">
|
||||||
{{ctx.Locale.Tr "loading"}}
|
<div class="editor-loading is-loading"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui tab" data-tab="diff">
|
<div class="ui tab" data-tab="diff">
|
||||||
<div class="tw-p-16"></div>
|
<div class="editor-loading is-loading"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="ui top attached header">
|
<div class="ui top attached header">
|
||||||
<div class="flex-text-block tw-justify-between tw-flex-wrap">
|
<div class="flex-text-block tw-justify-between tw-flex-wrap">
|
||||||
<div class="ui compact small menu small-menu-items repo-editor-menu tw-self-start">
|
<div class="ui compact small menu small-menu-items repo-editor-menu">
|
||||||
<a class="active item" data-tab="write">{{svg "octicon-code" 16 "tw-mr-1"}}{{ctx.Locale.Tr "repo.editor.new_patch"}}</a>
|
<a class="active item" data-tab="write">{{svg "octicon-code" 16 "tw-mr-1"}}{{ctx.Locale.Tr "repo.editor.new_patch"}}</a>
|
||||||
</div>
|
</div>
|
||||||
{{template "repo/editor/options" dict "CodeEditorConfig" $.CodeEditorConfig}}
|
{{template "repo/editor/options" dict "CodeEditorConfig" $.CodeEditorConfig}}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{{if .HasFilesWithoutLatestCommit}}
|
{{if .HasFilesWithoutLatestCommit}}
|
||||||
data-fetch-url="{{.LastCommitLoaderURL}}"
|
data-fetch-url="{{.LastCommitLoaderURL}}"
|
||||||
data-fetch-trigger="load" data-fetch-sync="$morph"
|
data-fetch-trigger="load" data-fetch-sync="$morph"
|
||||||
data-fetch-indicator="#repo-files-table .repo-file-cell.notready.message"
|
data-fetch-indicator=".repo-file-cell.notready.message"
|
||||||
{{end}}
|
{{end}}
|
||||||
>
|
>
|
||||||
<div class="repo-file-line repo-file-last-commit">
|
<div class="repo-file-line repo-file-last-commit">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ test('codeeditor textarea updates correctly', async ({page, request}) => {
|
|||||||
try {
|
try {
|
||||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/_new/main`);
|
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/_new/main`);
|
||||||
await page.getByPlaceholder('Name your file…').fill('test.js');
|
await page.getByPlaceholder('Name your file…').fill('test.js');
|
||||||
await expect(page.locator('.editor-loading')).toBeHidden();
|
await expect(page.locator('[data-tab="write"] .editor-loading')).toBeHidden();
|
||||||
const editor = page.locator('.cm-content[role="textbox"]');
|
const editor = page.locator('.cm-content[role="textbox"]');
|
||||||
await expect(editor).toBeVisible();
|
await expect(editor).toBeVisible();
|
||||||
await editor.click();
|
await editor.click();
|
||||||
|
|||||||
Vendored
-5
@@ -36,11 +36,6 @@ declare module '*.vue' {
|
|||||||
export function initRepositoryActionView(): void;
|
export function initRepositoryActionView(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'htmx.org/dist/htmx.esm.js' {
|
|
||||||
const value = await import('htmx.org');
|
|
||||||
export default value;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module 'idiomorph' {
|
declare module 'idiomorph' {
|
||||||
interface Idiomorph {
|
interface Idiomorph {
|
||||||
morph(existing: Node | string, replacement: Node | string, options?: {morphStyle: 'innerHTML' | 'outerHTML'}): void;
|
morph(existing: Node | string, replacement: Node | string, options?: {morphStyle: 'innerHTML' | 'outerHTML'}): void;
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ const webComponents = new Set([
|
|||||||
|
|
||||||
const commonRolldownOptions: Rolldown.RolldownOptions = {
|
const commonRolldownOptions: Rolldown.RolldownOptions = {
|
||||||
checks: {
|
checks: {
|
||||||
eval: false, // htmx needs eval
|
|
||||||
pluginTimings: false,
|
pluginTimings: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -187,10 +187,6 @@ td .commit-summary {
|
|||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo-editor-menu {
|
|
||||||
min-height: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.repo-editor-header {
|
.repo-editor-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import {execPseudoSelectorCommands} from './common-fetch-action.ts';
|
||||||
|
|
||||||
|
test('execPseudoSelectorCommands', () => {
|
||||||
|
window.document.body.innerHTML = `
|
||||||
|
<div id="d1">
|
||||||
|
<ul id="u1">
|
||||||
|
<li class="x"></li>
|
||||||
|
</ul>
|
||||||
|
<ul id="u2">
|
||||||
|
<li class="x"></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div id="d2">
|
||||||
|
<ul id="u3">
|
||||||
|
<li class="x"></li>
|
||||||
|
</ul>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
let ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '');
|
||||||
|
expect(ret.targets).toEqual([document.querySelector('#u1')]);
|
||||||
|
|
||||||
|
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$this');
|
||||||
|
expect(ret.targets).toEqual([document.querySelector('#u1')]);
|
||||||
|
expect(ret.cmdInnerHTML).toBeFalsy();
|
||||||
|
expect(ret.cmdMorph).toBeFalsy();
|
||||||
|
|
||||||
|
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$body $morph $innerHTML');
|
||||||
|
expect(ret.targets).toEqual([document.body]);
|
||||||
|
expect(ret.cmdInnerHTML).toBeTruthy();
|
||||||
|
expect(ret.cmdMorph).toBeTruthy();
|
||||||
|
|
||||||
|
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$body .x');
|
||||||
|
expect(ret.targets.length).toEqual(3);
|
||||||
|
expect(ret.targets).toEqual(Array.from(document.querySelectorAll('.x')));
|
||||||
|
|
||||||
|
ret = execPseudoSelectorCommands(document.querySelector('#u1 .x')!, '$closest(div) .x');
|
||||||
|
expect(ret.targets.length).toEqual(2);
|
||||||
|
expect(ret.targets).toEqual(Array.from(document.querySelectorAll('#d1 .x')));
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ import {Idiomorph} from 'idiomorph';
|
|||||||
import {parseDom} from '../utils.ts';
|
import {parseDom} from '../utils.ts';
|
||||||
import {html} from '../utils/html.ts';
|
import {html} from '../utils/html.ts';
|
||||||
|
|
||||||
const {appSubUrl} = window.config;
|
const {appSubUrl, runModeIsProd} = window.config;
|
||||||
|
|
||||||
type FetchActionOpts = {
|
type FetchActionOpts = {
|
||||||
method: string;
|
method: string;
|
||||||
@@ -20,15 +20,24 @@ type FetchActionOpts = {
|
|||||||
// e.g.: "$this", "$innerHTML", "$closest(tr) td .the-class", "$body #the-id"
|
// e.g.: "$this", "$innerHTML", "$closest(tr) td .the-class", "$body #the-id"
|
||||||
successSync: string;
|
successSync: string;
|
||||||
|
|
||||||
// null: no indicator
|
// the loading indicator element selector, it uses the same syntax as "data-fetch-sync" to find the element(s)
|
||||||
// empty string: the current element
|
// empty means no loading indicator, "$this" means the element itself
|
||||||
// '.css-selector': find the element by selector
|
loadingIndicator: string;
|
||||||
loadingIndicator: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location"
|
// fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location"
|
||||||
// more details are in the backend's fetch-redirect handler
|
// more details are in the backend's fetch-redirect handler
|
||||||
function fetchActionDoRedirect(redirect: string) {
|
function fetchActionDoRedirect(redirect: string) {
|
||||||
|
// In production, if the link can be directly navigated by browser, we just do normal redirection, which is faster.
|
||||||
|
// Otherwise, need to use backend to do redirection:
|
||||||
|
// * Also do so in development, to make sure the redirection logic is always tested by real users
|
||||||
|
const needBackendHelp = redirect.includes('#');
|
||||||
|
if (runModeIsProd && !needBackendHelp) {
|
||||||
|
window.location.href = redirect;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// use backend to do redirection, which can bypass the browser's limitations of "location"
|
||||||
const form = createElementFromHTML<HTMLFormElement>(html`<form method="post"></form>`);
|
const form = createElementFromHTML<HTMLFormElement>(html`<form method="post"></form>`);
|
||||||
form.action = `${appSubUrl}/-/fetch-redirect?redirect=${encodeURIComponent(redirect)}`;
|
form.action = `${appSubUrl}/-/fetch-redirect?redirect=${encodeURIComponent(redirect)}`;
|
||||||
document.body.append(form);
|
document.body.append(form);
|
||||||
@@ -36,9 +45,10 @@ function fetchActionDoRedirect(redirect: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleLoadingIndicator(el: HTMLElement, opt: FetchActionOpts, isLoading: boolean) {
|
function toggleLoadingIndicator(el: HTMLElement, opt: FetchActionOpts, isLoading: boolean) {
|
||||||
const loadingIndicatorElems = opt.loadingIndicator === null ? [] : (opt.loadingIndicator === '' ? [el] : document.querySelectorAll(opt.loadingIndicator));
|
const loadingIndicatorElems = opt.loadingIndicator ? execPseudoSelectorCommands(el, opt.loadingIndicator).targets : [];
|
||||||
for (const indicatorEl of loadingIndicatorElems) {
|
for (const indicatorEl of loadingIndicatorElems) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
// for button or input element, we can directly disable it, it looks better than adding a loading spinner
|
||||||
if ('disabled' in indicatorEl) {
|
if ('disabled' in indicatorEl) {
|
||||||
indicatorEl.disabled = true;
|
indicatorEl.disabled = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -57,7 +67,7 @@ function toggleLoadingIndicator(el: HTMLElement, opt: FetchActionOpts, isLoading
|
|||||||
|
|
||||||
async function handleFetchActionSuccessJson(el: HTMLElement, respJson: any) {
|
async function handleFetchActionSuccessJson(el: HTMLElement, respJson: any) {
|
||||||
ignoreAreYouSure(el); // ignore the areYouSure check before reloading
|
ignoreAreYouSure(el); // ignore the areYouSure check before reloading
|
||||||
if (respJson?.redirect) {
|
if (typeof respJson?.redirect === 'string') {
|
||||||
fetchActionDoRedirect(respJson.redirect);
|
fetchActionDoRedirect(respJson.redirect);
|
||||||
} else {
|
} else {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -168,7 +178,7 @@ function prepareFormFetchActionOpts(formEl: HTMLFormElement, opts: SubmitFormFet
|
|||||||
method: formMethodUpper,
|
method: formMethodUpper,
|
||||||
url: reqUrl,
|
url: reqUrl,
|
||||||
body: reqBody,
|
body: reqBody,
|
||||||
loadingIndicator: '', // for form submit, by default, the loading indicator is the whole form
|
loadingIndicator: '$this', // for form submit, by default, the loading indicator is the whole form
|
||||||
successSync: formEl.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for form submit
|
successSync: formEl.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for form submit
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -209,17 +219,17 @@ async function performLinkFetchAction(el: HTMLElement) {
|
|||||||
await performActionRequest(el, {
|
await performActionRequest(el, {
|
||||||
method: el.getAttribute('data-fetch-method') || 'POST', // by default, the method is POST for link-action
|
method: el.getAttribute('data-fetch-method') || 'POST', // by default, the method is POST for link-action
|
||||||
url: el.getAttribute('data-url')!,
|
url: el.getAttribute('data-url')!,
|
||||||
loadingIndicator: el.getAttribute('data-fetch-indicator') || '', // by default, the link-action itself is the loading indicator
|
loadingIndicator: el.getAttribute('data-fetch-indicator') ?? '$this', // by default, the link-action itself is the loading indicator
|
||||||
successSync: el.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for link-action
|
successSync: el.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for link-action
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type FetchActionTriggerType = 'click' | 'change' | 'every' | 'load' | 'fetch-reload';
|
type FetchActionTriggerType = 'click' | 'change' | 'every' | 'load' | 'fetch-reload';
|
||||||
|
|
||||||
async function performFetchActionTriggerRequest(el: HTMLElement, triggerType: FetchActionTriggerType) {
|
export async function performFetchActionTrigger(el: HTMLElement, triggerType: FetchActionTriggerType) {
|
||||||
const isUserInitiated = triggerType === 'click' || triggerType === 'change';
|
const isUserInitiated = triggerType === 'click' || triggerType === 'change';
|
||||||
// for user initiated action, by default, the loading indicator is the element itself, otherwise no loading indicator
|
// for user initiated action, by default, the loading indicator is the element itself, otherwise no loading indicator
|
||||||
const defaultLoadingIndicator = isUserInitiated ? '' : null;
|
const defaultLoadingIndicator = isUserInitiated ? '$this' : '';
|
||||||
|
|
||||||
if (isUserInitiated) hideToastsAll();
|
if (isUserInitiated) hideToastsAll();
|
||||||
await performActionRequest(el, {
|
await performActionRequest(el, {
|
||||||
@@ -230,28 +240,51 @@ async function performFetchActionTriggerRequest(el: HTMLElement, triggerType: Fe
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFetchActionSuccessSync(el: HTMLElement, successSync: string, respText: string) {
|
type PseudoSelectorCommandResult = {
|
||||||
const cmds = successSync.split(' ').map((s) => s.trim()).filter(Boolean) || [];
|
targets: Element[];
|
||||||
let target = el, replaceInner = false, useMorph = false;
|
cmdInnerHTML: boolean;
|
||||||
|
cmdMorph: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function execPseudoSelectorCommands(el: Element, fullCommand: string): PseudoSelectorCommandResult {
|
||||||
|
const cmds = fullCommand.split(' ').map((s) => s.trim()).filter(Boolean) || [];
|
||||||
|
let targets = [el], cmdInnerHTML = false, cmdMorph = false;
|
||||||
for (const cmd of cmds) {
|
for (const cmd of cmds) {
|
||||||
if (cmd === '$this') {
|
if (cmd === '$this') {
|
||||||
target = el;
|
targets = [el];
|
||||||
} else if (cmd === '$body') {
|
} else if (cmd === '$body') {
|
||||||
target = document.body;
|
targets = [document.body];
|
||||||
} else if (cmd === '$innerHTML') {
|
} else if (cmd === '$innerHTML') {
|
||||||
replaceInner = true;
|
cmdInnerHTML = true;
|
||||||
} else if (cmd === '$morph') {
|
} else if (cmd === '$morph') {
|
||||||
useMorph = true;
|
cmdMorph = true;
|
||||||
} else if (cmd.startsWith('$closest(') && cmd.endsWith(')')) {
|
} else if (cmd.startsWith('$closest(') && cmd.endsWith(')')) {
|
||||||
const selector = cmd.substring('$closest('.length, cmd.length - 1);
|
const selector = cmd.substring('$closest('.length, cmd.length - 1);
|
||||||
target = target.closest(selector) as HTMLElement;
|
const newTargets: Element[] = [];
|
||||||
|
for (const target of targets) {
|
||||||
|
const closest = target.closest(selector);
|
||||||
|
if (closest) newTargets.push(closest);
|
||||||
|
}
|
||||||
|
targets = newTargets;
|
||||||
} else {
|
} else {
|
||||||
target = target.querySelector(cmd) as HTMLElement;
|
const newTargets: Element[] = [];
|
||||||
|
for (const target of targets) {
|
||||||
|
newTargets.push(...target.querySelectorAll(cmd));
|
||||||
|
}
|
||||||
|
targets = newTargets;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (useMorph) {
|
return {targets, cmdInnerHTML, cmdMorph};
|
||||||
Idiomorph.morph(target, respText, {morphStyle: replaceInner ? 'innerHTML' : 'outerHTML'});
|
}
|
||||||
} else if (replaceInner) {
|
|
||||||
|
async function handleFetchActionSuccessSync(el: Element, successSync: string, respText: string) {
|
||||||
|
const res = execPseudoSelectorCommands(el, successSync);
|
||||||
|
if (!res.targets.length) throw new Error(`Fetch-sync command "${successSync}" did not find any target element to update`);
|
||||||
|
if (res.targets.length > 1) throw new Error(`Fetch-sync command "${successSync}" found multiple target elements, which is not supported`);
|
||||||
|
const target = res.targets[0];
|
||||||
|
if (res.cmdMorph) {
|
||||||
|
Idiomorph.morph(target, respText, {morphStyle: res.cmdInnerHTML ? 'innerHTML' : 'outerHTML'});
|
||||||
|
} else if (res.cmdInnerHTML) {
|
||||||
target.innerHTML = respText;
|
target.innerHTML = respText;
|
||||||
} else {
|
} else {
|
||||||
target.outerHTML = respText;
|
target.outerHTML = respText;
|
||||||
@@ -294,7 +327,7 @@ function initFetchActionTriggerEvery(el: HTMLElement, trigger: string) {
|
|||||||
const intervalMs = unit === 's' ? num * 1000 : num;
|
const intervalMs = unit === 's' ? num * 1000 : num;
|
||||||
const fn = async () => {
|
const fn = async () => {
|
||||||
try {
|
try {
|
||||||
await performFetchActionTriggerRequest(el, 'every');
|
await performFetchActionTrigger(el, 'every');
|
||||||
} finally {
|
} finally {
|
||||||
// only continue if the element is still in the document
|
// only continue if the element is still in the document
|
||||||
if (document.contains(el)) {
|
if (document.contains(el)) {
|
||||||
@@ -312,15 +345,15 @@ function initFetchActionTrigger(el: HTMLElement) {
|
|||||||
if (trigger === 'fetch-reload') return;
|
if (trigger === 'fetch-reload') return;
|
||||||
|
|
||||||
if (trigger === 'load') {
|
if (trigger === 'load') {
|
||||||
performFetchActionTriggerRequest(el, trigger);
|
performFetchActionTrigger(el, trigger);
|
||||||
} else if (trigger === 'change') {
|
} else if (trigger === 'change') {
|
||||||
el.addEventListener('change', () => performFetchActionTriggerRequest(el, trigger));
|
el.addEventListener('change', () => performFetchActionTrigger(el, trigger));
|
||||||
} else if (trigger?.startsWith('every ')) {
|
} else if (trigger?.startsWith('every ')) {
|
||||||
initFetchActionTriggerEvery(el, trigger);
|
initFetchActionTriggerEvery(el, trigger);
|
||||||
} else if (!trigger || trigger === 'click') {
|
} else if (!trigger || trigger === 'click') {
|
||||||
el.addEventListener('click', (e) => {
|
el.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
performFetchActionTriggerRequest(el, 'click');
|
performFetchActionTrigger(el, 'click');
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unsupported fetch trigger: ${trigger}`);
|
throw new Error(`Unsupported fetch trigger: ${trigger}`);
|
||||||
@@ -328,9 +361,11 @@ function initFetchActionTrigger(el: HTMLElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initGlobalFetchAction() {
|
export function initGlobalFetchAction() {
|
||||||
// "fetch-action" is a general approach for elements to trigger fetch requests:
|
// The "fetch-action" framework is a general approach for elements to trigger fetch requests:
|
||||||
// show confirm dialog (if any), show loading indicators, send fetch request, and redirect or update UI after success.
|
// show confirm dialog (if any), show loading indicators, send fetch request, and redirect or update UI after success.
|
||||||
//
|
//
|
||||||
|
// If you need more fine-grained control more details, sometimes it's clearer to write the logic in JavaScript, instead of using this generic framework.
|
||||||
|
//
|
||||||
// Attributes:
|
// Attributes:
|
||||||
//
|
//
|
||||||
// * data-fetch-method: the HTTP method to use
|
// * data-fetch-method: the HTTP method to use
|
||||||
@@ -345,12 +380,12 @@ export function initGlobalFetchAction() {
|
|||||||
// * "every 5s" (also support "ms" unit)
|
// * "every 5s" (also support "ms" unit)
|
||||||
// * "fetch-reload" (only triggered by fetch sync success to reload outdated content)
|
// * "fetch-reload" (only triggered by fetch sync success to reload outdated content)
|
||||||
//
|
//
|
||||||
// * data-fetch-indicator: the loading indicator element selector
|
// * data-fetch-indicator: the loading indicator element selector, it uses the same syntax as "data-fetch-sync" to find the element(s)
|
||||||
//
|
//
|
||||||
// * data-fetch-sync: when the response is text (html), the pseudo selectors/commands defined in "data-fetch-sync"
|
// * data-fetch-sync: when the response is text (html), the pseudo selectors/commands defined in "data-fetch-sync"
|
||||||
// will be used to update the content in the current page. It only supports some simple syntaxes that we need.
|
// will be used to update the content in the current page. It only supports some simple syntaxes that we need.
|
||||||
// "$" prefix means it is our private command (for special logic)
|
// "$" prefix means it is our private command (for special logic), the selectors are run one by one from current element.
|
||||||
// * "" (empty string): replace the current element with the response
|
// * "$this": replace the current element with the response
|
||||||
// * "$innerHTML": replace innerHTML of the current element with the response, instead of replacing the whole element (outerHTML)
|
// * "$innerHTML": replace innerHTML of the current element with the response, instead of replacing the whole element (outerHTML)
|
||||||
// * "$morph": use morph algorithm to update the target element
|
// * "$morph": use morph algorithm to update the target element
|
||||||
// * "$body #the-id .the-class": query the selector one by one from body
|
// * "$body #the-id .the-class": query the selector one by one from body
|
||||||
@@ -358,7 +393,7 @@ export function initGlobalFetchAction() {
|
|||||||
//
|
//
|
||||||
// * data-modal-confirm: a "confirm modal dialog" will be shown before taking action.
|
// * data-modal-confirm: a "confirm modal dialog" will be shown before taking action.
|
||||||
// * it can be a string for the content of the modal dialog
|
// * it can be a string for the content of the modal dialog
|
||||||
// * it has "-header" and "-content" variants to set the header and content of the confirm modal
|
// * it has "-header" and "-content" variants to set the header and content of the "confirm modal"
|
||||||
// * it can refer an existing modal element by "#the-modal-id"
|
// * it can refer an existing modal element by "#the-modal-id"
|
||||||
|
|
||||||
addDelegatedEventListener(document, 'submit', '.form-fetch-action', async (el: HTMLFormElement, e) => {
|
addDelegatedEventListener(document, 'submit', '.form-fetch-action', async (el: HTMLFormElement, e) => {
|
||||||
@@ -369,7 +404,7 @@ export function initGlobalFetchAction() {
|
|||||||
|
|
||||||
addDelegatedEventListener(document, 'click', '.link-action', async (el, e) => {
|
addDelegatedEventListener(document, 'click', '.link-action', async (el, e) => {
|
||||||
// `<a class="link-action" data-url="...">` is a shorthand for
|
// `<a class="link-action" data-url="...">` is a shorthand for
|
||||||
// `<a data-fetch-trigger="click" data-fetch-method="post" data-fetch-url="..." data-fetch-indicator="">`
|
// `<a data-fetch-trigger="click" data-fetch-method="post" data-fetch-url="..." data-fetch-indicator="$this">`
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
await performLinkFetchAction(el);
|
await performLinkFetchAction(el);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -76,28 +76,26 @@ async function updateNotificationCountWithCallback(callback: (timeout: number, n
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateNotificationTable() {
|
async function updateNotificationTable() {
|
||||||
let notificationDiv = document.querySelector('#notification_div');
|
const notificationDiv = document.querySelector('#notification_div');
|
||||||
if (notificationDiv) {
|
if (!notificationDiv) return;
|
||||||
try {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
params.set('div-only', 'true');
|
|
||||||
params.set('sequence-number', String(++notificationSequenceNumber));
|
|
||||||
const response = await GET(`${appSubUrl}/notifications?${params.toString()}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
try {
|
||||||
throw new Error('Failed to fetch notification table');
|
const params = new URLSearchParams(window.location.search);
|
||||||
}
|
params.set('div-only', 'true');
|
||||||
|
params.set('sequence-number', String(++notificationSequenceNumber));
|
||||||
|
const response = await GET(`${appSubUrl}/notifications?${params.toString()}`);
|
||||||
|
|
||||||
const data = await response.text();
|
if (!response.ok) {
|
||||||
const el = createElementFromHTML(data);
|
throw new Error('Failed to fetch notification table');
|
||||||
if (parseInt(el.getAttribute('data-sequence-number')!) === notificationSequenceNumber) {
|
|
||||||
notificationDiv.outerHTML = data;
|
|
||||||
notificationDiv = document.querySelector('#notification_div')!;
|
|
||||||
window.htmx.process(notificationDiv); // when using htmx, we must always remember to process the new content changed by us
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = await response.text();
|
||||||
|
const el = createElementFromHTML(data);
|
||||||
|
if (parseInt(el.getAttribute('data-sequence-number')!) === notificationSequenceNumber) {
|
||||||
|
notificationDiv.outerHTML = data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import {submitEventSubmitter, queryElemSiblings, hideElem, showElem, animateOnce
|
|||||||
import {POST, GET} from '../modules/fetch.ts';
|
import {POST, GET} from '../modules/fetch.ts';
|
||||||
import {createTippy} from '../modules/tippy.ts';
|
import {createTippy} from '../modules/tippy.ts';
|
||||||
import {invertFileFolding} from './file-fold.ts';
|
import {invertFileFolding} from './file-fold.ts';
|
||||||
import {parseDom, sleep} from '../utils.ts';
|
import {parseDom} from '../utils.ts';
|
||||||
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
|
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
|
||||||
|
import {performFetchActionTrigger} from './common-fetch-action.ts';
|
||||||
|
|
||||||
function initRepoDiffFileBox(el: HTMLElement) {
|
function initRepoDiffFileBox(el: HTMLElement) {
|
||||||
// switch between "rendered" and "source", for image and CSV files
|
// switch between "rendered" and "source", for image and CSV files
|
||||||
@@ -172,7 +173,6 @@ async function loadMoreFiles(btn: Element): Promise<boolean> {
|
|||||||
// * append the newly loaded file list items to the existing list
|
// * append the newly loaded file list items to the existing list
|
||||||
const respFileBoxesChildren = Array.from(respFileBoxes.children); // "children:HTMLCollection" will be empty after replaceWith
|
const respFileBoxesChildren = Array.from(respFileBoxes.children); // "children:HTMLCollection" will be empty after replaceWith
|
||||||
document.querySelector('#diff-incomplete')!.replaceWith(...respFileBoxesChildren);
|
document.querySelector('#diff-incomplete')!.replaceWith(...respFileBoxesChildren);
|
||||||
for (const el of respFileBoxesChildren) window.htmx.process(el);
|
|
||||||
onShowMoreFiles();
|
onShowMoreFiles();
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -204,7 +204,6 @@ function initRepoDiffShowMore() {
|
|||||||
const respFileBody = respDoc.querySelector('#diff-file-boxes .diff-file-body .file-body')!;
|
const respFileBody = respDoc.querySelector('#diff-file-boxes .diff-file-body .file-body')!;
|
||||||
const respFileBodyChildren = Array.from(respFileBody.children); // "children:HTMLCollection" will be empty after replaceWith
|
const respFileBodyChildren = Array.from(respFileBody.children); // "children:HTMLCollection" will be empty after replaceWith
|
||||||
el.parentElement!.replaceWith(...respFileBodyChildren);
|
el.parentElement!.replaceWith(...respFileBodyChildren);
|
||||||
for (const el of respFileBodyChildren) window.htmx.process(el);
|
|
||||||
// FIXME: calling onShowMoreFiles is not quite right here.
|
// FIXME: calling onShowMoreFiles is not quite right here.
|
||||||
// But since onShowMoreFiles mixes "init diff box" and "init diff body" together,
|
// But since onShowMoreFiles mixes "init diff box" and "init diff body" together,
|
||||||
// so it still needs to call it to make the "ImageDiff" and something similar work.
|
// so it still needs to call it to make the "ImageDiff" and something similar work.
|
||||||
@@ -251,8 +250,8 @@ async function onLocationHashChange() {
|
|||||||
const attrAutoLoadClicked = 'data-auto-load-clicked';
|
const attrAutoLoadClicked = 'data-auto-load-clicked';
|
||||||
if (expandButton.hasAttribute(attrAutoLoadClicked)) return;
|
if (expandButton.hasAttribute(attrAutoLoadClicked)) return;
|
||||||
expandButton.setAttribute(attrAutoLoadClicked, 'true');
|
expandButton.setAttribute(attrAutoLoadClicked, 'true');
|
||||||
expandButton.click();
|
// trigger the fetch action to load the hidden comments, after loading, it will try to find the target element again
|
||||||
await sleep(500); // Wait for HTMX to load the content. FIXME: need to drop htmx in the future
|
await performFetchActionTrigger(expandButton, 'load');
|
||||||
continue; // Try again to find the element
|
continue; // Try again to find the element
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,32 +6,60 @@ import {POST} from '../modules/fetch.ts';
|
|||||||
import {initDropzone} from './dropzone.ts';
|
import {initDropzone} from './dropzone.ts';
|
||||||
import {confirmModal} from './comp/ConfirmModal.ts';
|
import {confirmModal} from './comp/ConfirmModal.ts';
|
||||||
import {applyAreYouSure, ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
import {applyAreYouSure, ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
||||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
|
||||||
import {submitFormFetchAction} from './common-fetch-action.ts';
|
import {submitFormFetchAction} from './common-fetch-action.ts';
|
||||||
|
import {dirname} from '../utils.ts';
|
||||||
|
import {pathEscapeSegments} from '../utils/url.ts';
|
||||||
|
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||||
|
import {showErrorToast} from '../modules/toast.ts';
|
||||||
|
|
||||||
function initEditPreviewTab(elForm: HTMLFormElement) {
|
function initEditPreviewTab(elForm: HTMLFormElement) {
|
||||||
const elTabMenu = elForm.querySelector('.repo-editor-menu')!;
|
const elTabMenu = elForm.querySelector('.repo-editor-menu');
|
||||||
|
if (!elTabMenu) return;
|
||||||
fomanticQuery(elTabMenu.querySelectorAll('.item')).tab();
|
fomanticQuery(elTabMenu.querySelectorAll('.item')).tab();
|
||||||
|
|
||||||
const elPreviewTab = elTabMenu.querySelector('a[data-tab="preview"]');
|
const elTreePath = elForm.querySelector<HTMLInputElement>('input#tree_path');
|
||||||
const elPreviewPanel = elForm.querySelector('.tab[data-tab="preview"]');
|
const elTextarea = elForm.querySelector<HTMLTextAreaElement>('.tab[data-tab="write"] textarea');
|
||||||
if (!elPreviewTab || !elPreviewPanel) return;
|
if (!elTreePath || !elTextarea) return;
|
||||||
|
|
||||||
|
const repoLink = elTabMenu.getAttribute('data-repo-link')!;
|
||||||
|
const refSubUrl = elTabMenu.getAttribute('data-ref-sub-url')!;
|
||||||
|
const branchName = elTabMenu.getAttribute('data-branch-name')!;
|
||||||
|
|
||||||
|
const elPreviewTab = elTabMenu.querySelector('a[data-tab="preview"]')!;
|
||||||
|
const elPreviewPanel = elForm.querySelector('.tab[data-tab="preview"]')!;
|
||||||
elPreviewTab.addEventListener('click', async () => {
|
elPreviewTab.addEventListener('click', async () => {
|
||||||
const elTreePath = elForm.querySelector<HTMLInputElement>('input#tree_path')!;
|
// "preview context" is the request path directory of the file, the rendered links will be resolved based on this path
|
||||||
const previewUrl = elPreviewTab.getAttribute('data-preview-url')!;
|
// TODO: MARKUP-RENDER-CONTEXT: due to various hacky patches, this logic is unnecessarily complicated, see the backend
|
||||||
const previewContextRef = elPreviewTab.getAttribute('data-preview-context-ref');
|
const previewContext = dirname(`${repoLink}/src/${refSubUrl}/${pathEscapeSegments(elTreePath.value)}`);
|
||||||
let previewContext = `${previewContextRef}/${elTreePath.value}`;
|
|
||||||
previewContext = previewContext.substring(0, previewContext.lastIndexOf('/'));
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('mode', 'file');
|
formData.append('mode', 'file');
|
||||||
formData.append('context', previewContext);
|
formData.append('context', previewContext);
|
||||||
formData.append('text', elForm.querySelector<HTMLTextAreaElement>('.tab[data-tab="write"] textarea')!.value);
|
formData.append('text', elTextarea.value);
|
||||||
formData.append('file_path', elTreePath.value);
|
formData.append('file_path', elTreePath.value);
|
||||||
const response = await POST(previewUrl, {data: formData});
|
const resp = await POST(`${repoLink}/markup`, {data: formData});
|
||||||
const data = await response.text();
|
if (!resp.ok) {
|
||||||
|
showErrorToast(`Failed to render preview: ${resp.status} ${resp.statusText}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.text();
|
||||||
renderPreviewPanelContent(elPreviewPanel, data);
|
renderPreviewPanelContent(elPreviewPanel, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const elDiffTab = elTabMenu.querySelector('a[data-tab="diff"]');
|
||||||
|
const elDiffPanel = elForm.querySelector('.tab[data-tab="diff"]');
|
||||||
|
if (elDiffTab && elDiffPanel) {
|
||||||
|
// the "diff" tab only exists for an existing file, but not for a new file
|
||||||
|
elDiffTab.addEventListener('click', async () => {
|
||||||
|
const diffUrl = `${repoLink}/_preview/${pathEscapeSegments(branchName)}/${pathEscapeSegments(elTreePath.value)}`;
|
||||||
|
// don't use FormData, because FormData sends "\r\n" line endings, backend assumes "\n" line endings
|
||||||
|
const resp = await POST(diffUrl, {data: new URLSearchParams({content: elTextarea.value})});
|
||||||
|
if (!resp.ok) {
|
||||||
|
showErrorToast(`Failed to render diff: ${resp.status} ${resp.statusText}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
elDiffPanel.innerHTML = await resp.text();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initRepoEditor() {
|
export function initRepoEditor() {
|
||||||
@@ -54,6 +82,8 @@ export function initRepoEditor() {
|
|||||||
// ATTENTION: two pages have this filename input
|
// ATTENTION: two pages have this filename input
|
||||||
// * new/edit file page: there is a code editor
|
// * new/edit file page: there is a code editor
|
||||||
// * upload page: there is no code editor, but a uploader
|
// * upload page: there is no code editor, but a uploader
|
||||||
|
// FIXME: the related logic is totally a mess, need to completely rewrite, that's also the root reason for
|
||||||
|
// why the "migrate to CodeMirror" PR took very long time on the legacy code and introduced "#file-name (filenameInput)" regressions many times
|
||||||
const filenameInput = document.querySelector<HTMLInputElement>('#file-name')!;
|
const filenameInput = document.querySelector<HTMLInputElement>('#file-name')!;
|
||||||
if (!filenameInput) return;
|
if (!filenameInput) return;
|
||||||
filenameInput.value = filenameInput.defaultValue; // prevent browser from restoring form values on refresh
|
filenameInput.value = filenameInput.defaultValue; // prevent browser from restoring form values on refresh
|
||||||
|
|||||||
@@ -28,12 +28,10 @@ export function syncIssueMainContentTimelineItems(oldMainContent: Element, newMa
|
|||||||
// for event item (e.g.: "add & remove labels"), we want to replace the existing one if exists
|
// for event item (e.g.: "add & remove labels"), we want to replace the existing one if exists
|
||||||
// because the label operations can be merged into one event item, so the new item might be different from the old one
|
// because the label operations can be merged into one event item, so the new item might be different from the old one
|
||||||
oldItem.replaceWith(newItem);
|
oldItem.replaceWith(newItem);
|
||||||
window.htmx.process(newItem);
|
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
timelineEnd.insertAdjacentElement('beforebegin', newItem);
|
timelineEnd.insertAdjacentElement('beforebegin', newItem);
|
||||||
window.htmx.process(newItem);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +90,6 @@ export class IssueSidebarComboList {
|
|||||||
// we can safely replace the whole right part (sidebar) because there are only some dropdowns and lists
|
// we can safely replace the whole right part (sidebar) because there are only some dropdowns and lists
|
||||||
const newSidebar = doc.querySelector('.issue-content-right')!;
|
const newSidebar = doc.querySelector('.issue-content-right')!;
|
||||||
this.elIssueSidebar.replaceWith(newSidebar);
|
this.elIssueSidebar.replaceWith(newSidebar);
|
||||||
window.htmx.process(newSidebar);
|
|
||||||
|
|
||||||
// for the main content (left side), at the moment we only support handling known timeline items
|
// for the main content (left side), at the moment we only support handling known timeline items
|
||||||
const newMainContent = doc.querySelector('.issue-content-left')!;
|
const newMainContent = doc.querySelector('.issue-content-left')!;
|
||||||
|
|||||||
Vendored
-1
@@ -56,7 +56,6 @@ interface Window {
|
|||||||
},
|
},
|
||||||
$: JQueryStatic,
|
$: JQueryStatic,
|
||||||
jQuery: JQueryStatic,
|
jQuery: JQueryStatic,
|
||||||
htmx: typeof import('htmx.org').default,
|
|
||||||
_globalHandlerErrors: Array<ErrorEvent & PromiseRejectionEvent> & {
|
_globalHandlerErrors: Array<ErrorEvent & PromiseRejectionEvent> & {
|
||||||
_inited: boolean,
|
_inited: boolean,
|
||||||
push: (e: ErrorEvent & PromiseRejectionEvent) => void | number,
|
push: (e: ErrorEvent & PromiseRejectionEvent) => void | number,
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import jquery from 'jquery'; // eslint-disable-line no-restricted-imports
|
import jquery from 'jquery'; // eslint-disable-line no-restricted-imports
|
||||||
import htmx from 'htmx.org'; // eslint-disable-line no-restricted-imports
|
|
||||||
|
|
||||||
// Some users still use inline scripts and expect jQuery to be available globally.
|
// Some users still use inline scripts and expect jQuery to be available globally.
|
||||||
// To avoid breaking existing users and custom plugins, import jQuery globally without ES module.
|
// To avoid breaking existing users and custom plugins, import jQuery globally without ES module.
|
||||||
window.$ = window.jQuery = jquery;
|
window.$ = window.jQuery = jquery;
|
||||||
|
|
||||||
// There is a bug in htmx, it incorrectly checks "readyState === 'complete'" when the DOM tree is ready and won't trigger DOMContentLoaded
|
|
||||||
// The bug makes htmx impossible to be loaded from an ES module: importing the htmx in onDomReady will make htmx skip its initialization.
|
|
||||||
// ref: https://github.com/bigskysoftware/htmx/pull/3365
|
|
||||||
window.htmx = htmx;
|
|
||||||
|
|
||||||
// https://htmx.org/reference/#config
|
|
||||||
htmx.config.requestClass = 'is-loading';
|
|
||||||
htmx.config.scrollIntoViewOnBoost = false;
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import '../fomantic/build/fomantic.js';
|
import '../fomantic/build/fomantic.js';
|
||||||
import '../css/index.css';
|
import '../css/index.css';
|
||||||
import type {HtmxResponseInfo} from 'htmx.org';
|
|
||||||
import {showErrorToast} from './modules/toast.ts';
|
|
||||||
|
|
||||||
import {initDashboardRepoList} from './features/dashboard.ts';
|
import {initDashboardRepoList} from './features/dashboard.ts';
|
||||||
import {initGlobalCopyToClipboardListener} from './features/clipboard.ts';
|
import {initGlobalCopyToClipboardListener} from './features/clipboard.ts';
|
||||||
@@ -173,15 +171,3 @@ const initDur = performance.now() - initStartTime;
|
|||||||
if (initDur > 500) {
|
if (initDur > 500) {
|
||||||
console.error(`slow init functions took ${initDur.toFixed(3)}ms`);
|
console.error(`slow init functions took ${initDur.toFixed(3)}ms`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://htmx.org/events/#htmx:sendError
|
|
||||||
type HtmxEvent = Event & {detail: HtmxResponseInfo};
|
|
||||||
document.body.addEventListener('htmx:sendError', (event) => {
|
|
||||||
// TODO: add translations
|
|
||||||
showErrorToast(`Network error when calling ${(event as HtmxEvent).detail.requestConfig.path}`);
|
|
||||||
});
|
|
||||||
// https://htmx.org/events/#htmx:responseError
|
|
||||||
document.body.addEventListener('htmx:responseError', (event) => {
|
|
||||||
// TODO: add translations
|
|
||||||
showErrorToast(`Error ${(event as HtmxEvent).detail.xhr.status} when calling ${(event as HtmxEvent).detail.requestConfig.path}`);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
// Stub APIs not implemented by happy-dom but needed by dependencies
|
import './globals.ts';
|
||||||
// XPathEvaluator is used by htmx at module evaluation time
|
|
||||||
// TODO: Remove after https://github.com/capricorn86/happy-dom/pull/2103 is released
|
|
||||||
if (!globalThis.XPathEvaluator) {
|
|
||||||
globalThis.XPathEvaluator = class {
|
|
||||||
createExpression() { return {evaluate: () => ({iterateNext: () => null})} }
|
|
||||||
} as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dynamic import so polyfills above are applied before htmx evaluates
|
|
||||||
await import('./globals.ts');
|
|
||||||
|
|
||||||
window.config = {
|
window.config = {
|
||||||
appUrl: 'http://localhost:3000/',
|
appUrl: 'http://localhost:3000/',
|
||||||
|
|||||||
Reference in New Issue
Block a user