mirror of https://github.com/go-gitea/gitea.git
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>pull/37232/head^2
parent
17f62bfec5
commit
2644bb8490
|
|
@ -574,7 +574,6 @@ export default defineConfig([
|
|||
'no-restricted-properties': [2, ...restrictedProperties],
|
||||
'no-restricted-imports': [2, {paths: [
|
||||
{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-return-assign': [0],
|
||||
|
|
@ -1021,6 +1020,6 @@ export default defineConfig([
|
|||
},
|
||||
{
|
||||
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",
|
||||
"easymde": "2.20.0",
|
||||
"esbuild": "0.28.0",
|
||||
"htmx.org": "2.0.8",
|
||||
"idiomorph": "0.7.4",
|
||||
"jquery": "4.0.0",
|
||||
"js-yaml": "4.1.1",
|
||||
|
|
|
|||
|
|
@ -157,9 +157,6 @@ importers:
|
|||
esbuild:
|
||||
specifier: 0.28.0
|
||||
version: 0.28.0
|
||||
htmx.org:
|
||||
specifier: 2.0.8
|
||||
version: 2.0.8
|
||||
idiomorph:
|
||||
specifier: 0.7.4
|
||||
version: 0.7.4
|
||||
|
|
@ -2768,9 +2765,6 @@ packages:
|
|||
htmlparser2@8.0.2:
|
||||
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
|
||||
|
||||
htmx.org@2.0.8:
|
||||
resolution: {integrity: sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==}
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -6789,8 +6783,6 @@ snapshots:
|
|||
domutils: 3.2.2
|
||||
entities: 4.5.0
|
||||
|
||||
htmx.org@2.0.8: {}
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
dependencies:
|
||||
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 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"
|
||||
// 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}}">
|
||||
{{if eq .Type "choice"}}
|
||||
<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}}">
|
||||
{{range .Options}}
|
||||
<option value="{{.}}" {{if eq $item.Default .}}selected{{end}}>{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
{{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">
|
||||
<input type="checkbox" name="{{.Name}}" {{if eq .Default "true"}}checked{{end}}>
|
||||
{{or .Description .Name}}
|
||||
|
|
|
|||
|
|
@ -19,13 +19,11 @@
|
|||
<div class="field">
|
||||
<div class="ui top attached header">
|
||||
<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="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}}
|
||||
{{/*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*/}}
|
||||
<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>
|
||||
<a class="item" data-tab="diff">{{svg "octicon-diff"}} {{ctx.Locale.Tr "repo.editor.preview_changes"}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{template "repo/editor/options" dict "CodeEditorConfig" $.CodeEditorConfig}}
|
||||
|
|
@ -39,10 +37,10 @@
|
|||
<div class="editor-loading is-loading"></div>
|
||||
</div>
|
||||
<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 class="ui tab" data-tab="diff">
|
||||
<div class="tw-p-16"></div>
|
||||
<div class="editor-loading is-loading"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
<div class="field">
|
||||
<div class="ui top attached header">
|
||||
<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>
|
||||
</div>
|
||||
{{template "repo/editor/options" dict "CodeEditorConfig" $.CodeEditorConfig}}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{{if .HasFilesWithoutLatestCommit}}
|
||||
data-fetch-url="{{.LastCommitLoaderURL}}"
|
||||
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}}
|
||||
>
|
||||
<div class="repo-file-line repo-file-last-commit">
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ test('codeeditor textarea updates correctly', async ({page, request}) => {
|
|||
try {
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/_new/main`);
|
||||
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"]');
|
||||
await expect(editor).toBeVisible();
|
||||
await editor.click();
|
||||
|
|
|
|||
|
|
@ -36,11 +36,6 @@ declare module '*.vue' {
|
|||
export function initRepositoryActionView(): void;
|
||||
}
|
||||
|
||||
declare module 'htmx.org/dist/htmx.esm.js' {
|
||||
const value = await import('htmx.org');
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module 'idiomorph' {
|
||||
interface Idiomorph {
|
||||
morph(existing: Node | string, replacement: Node | string, options?: {morphStyle: 'innerHTML' | 'outerHTML'}): void;
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ const webComponents = new Set([
|
|||
|
||||
const commonRolldownOptions: Rolldown.RolldownOptions = {
|
||||
checks: {
|
||||
eval: false, // htmx needs eval
|
||||
pluginTimings: false,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -187,10 +187,6 @@ td .commit-summary {
|
|||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.repo-editor-menu {
|
||||
min-height: auto !important;
|
||||
}
|
||||
|
||||
.repo-editor-header {
|
||||
display: flex;
|
||||
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 {html} from '../utils/html.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
const {appSubUrl, runModeIsProd} = window.config;
|
||||
|
||||
type FetchActionOpts = {
|
||||
method: string;
|
||||
|
|
@ -20,15 +20,24 @@ type FetchActionOpts = {
|
|||
// e.g.: "$this", "$innerHTML", "$closest(tr) td .the-class", "$body #the-id"
|
||||
successSync: string;
|
||||
|
||||
// null: no indicator
|
||||
// empty string: the current element
|
||||
// '.css-selector': find the element by selector
|
||||
loadingIndicator: string | null;
|
||||
// the loading indicator element selector, it uses the same syntax as "data-fetch-sync" to find the element(s)
|
||||
// empty means no loading indicator, "$this" means the element itself
|
||||
loadingIndicator: string;
|
||||
};
|
||||
|
||||
// fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location"
|
||||
// more details are in the backend's fetch-redirect handler
|
||||
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>`);
|
||||
form.action = `${appSubUrl}/-/fetch-redirect?redirect=${encodeURIComponent(redirect)}`;
|
||||
document.body.append(form);
|
||||
|
|
@ -36,9 +45,10 @@ function fetchActionDoRedirect(redirect: string) {
|
|||
}
|
||||
|
||||
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) {
|
||||
if (isLoading) {
|
||||
// for button or input element, we can directly disable it, it looks better than adding a loading spinner
|
||||
if ('disabled' in indicatorEl) {
|
||||
indicatorEl.disabled = true;
|
||||
} else {
|
||||
|
|
@ -57,7 +67,7 @@ function toggleLoadingIndicator(el: HTMLElement, opt: FetchActionOpts, isLoading
|
|||
|
||||
async function handleFetchActionSuccessJson(el: HTMLElement, respJson: any) {
|
||||
ignoreAreYouSure(el); // ignore the areYouSure check before reloading
|
||||
if (respJson?.redirect) {
|
||||
if (typeof respJson?.redirect === 'string') {
|
||||
fetchActionDoRedirect(respJson.redirect);
|
||||
} else {
|
||||
window.location.reload();
|
||||
|
|
@ -168,7 +178,7 @@ function prepareFormFetchActionOpts(formEl: HTMLFormElement, opts: SubmitFormFet
|
|||
method: formMethodUpper,
|
||||
url: reqUrl,
|
||||
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
|
||||
};
|
||||
}
|
||||
|
|
@ -209,17 +219,17 @@ async function performLinkFetchAction(el: HTMLElement) {
|
|||
await performActionRequest(el, {
|
||||
method: el.getAttribute('data-fetch-method') || 'POST', // by default, the method is POST for link-action
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
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';
|
||||
// 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();
|
||||
await performActionRequest(el, {
|
||||
|
|
@ -230,28 +240,51 @@ async function performFetchActionTriggerRequest(el: HTMLElement, triggerType: Fe
|
|||
});
|
||||
}
|
||||
|
||||
async function handleFetchActionSuccessSync(el: HTMLElement, successSync: string, respText: string) {
|
||||
const cmds = successSync.split(' ').map((s) => s.trim()).filter(Boolean) || [];
|
||||
let target = el, replaceInner = false, useMorph = false;
|
||||
type PseudoSelectorCommandResult = {
|
||||
targets: Element[];
|
||||
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) {
|
||||
if (cmd === '$this') {
|
||||
target = el;
|
||||
targets = [el];
|
||||
} else if (cmd === '$body') {
|
||||
target = document.body;
|
||||
targets = [document.body];
|
||||
} else if (cmd === '$innerHTML') {
|
||||
replaceInner = true;
|
||||
cmdInnerHTML = true;
|
||||
} else if (cmd === '$morph') {
|
||||
useMorph = true;
|
||||
cmdMorph = true;
|
||||
} else if (cmd.startsWith('$closest(') && cmd.endsWith(')')) {
|
||||
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 {
|
||||
target = target.querySelector(cmd) as HTMLElement;
|
||||
const newTargets: Element[] = [];
|
||||
for (const target of targets) {
|
||||
newTargets.push(...target.querySelectorAll(cmd));
|
||||
}
|
||||
targets = newTargets;
|
||||
}
|
||||
}
|
||||
if (useMorph) {
|
||||
Idiomorph.morph(target, respText, {morphStyle: replaceInner ? 'innerHTML' : 'outerHTML'});
|
||||
} else if (replaceInner) {
|
||||
return {targets, cmdInnerHTML, cmdMorph};
|
||||
}
|
||||
|
||||
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;
|
||||
} else {
|
||||
target.outerHTML = respText;
|
||||
|
|
@ -294,7 +327,7 @@ function initFetchActionTriggerEvery(el: HTMLElement, trigger: string) {
|
|||
const intervalMs = unit === 's' ? num * 1000 : num;
|
||||
const fn = async () => {
|
||||
try {
|
||||
await performFetchActionTriggerRequest(el, 'every');
|
||||
await performFetchActionTrigger(el, 'every');
|
||||
} finally {
|
||||
// only continue if the element is still in the document
|
||||
if (document.contains(el)) {
|
||||
|
|
@ -312,15 +345,15 @@ function initFetchActionTrigger(el: HTMLElement) {
|
|||
if (trigger === 'fetch-reload') return;
|
||||
|
||||
if (trigger === 'load') {
|
||||
performFetchActionTriggerRequest(el, trigger);
|
||||
performFetchActionTrigger(el, trigger);
|
||||
} else if (trigger === 'change') {
|
||||
el.addEventListener('change', () => performFetchActionTriggerRequest(el, trigger));
|
||||
el.addEventListener('change', () => performFetchActionTrigger(el, trigger));
|
||||
} else if (trigger?.startsWith('every ')) {
|
||||
initFetchActionTriggerEvery(el, trigger);
|
||||
} else if (!trigger || trigger === 'click') {
|
||||
el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
performFetchActionTriggerRequest(el, 'click');
|
||||
performFetchActionTrigger(el, 'click');
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unsupported fetch trigger: ${trigger}`);
|
||||
|
|
@ -328,9 +361,11 @@ function initFetchActionTrigger(el: HTMLElement) {
|
|||
}
|
||||
|
||||
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.
|
||||
//
|
||||
// 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:
|
||||
//
|
||||
// * data-fetch-method: the HTTP method to use
|
||||
|
|
@ -345,12 +380,12 @@ export function initGlobalFetchAction() {
|
|||
// * "every 5s" (also support "ms" unit)
|
||||
// * "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"
|
||||
// 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)
|
||||
// * "" (empty string): replace the current element with the response
|
||||
// "$" prefix means it is our private command (for special logic), the selectors are run one by one from current element.
|
||||
// * "$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)
|
||||
// * "$morph": use morph algorithm to update the target element
|
||||
// * "$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.
|
||||
// * 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"
|
||||
|
||||
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) => {
|
||||
// `<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();
|
||||
await performLinkFetchAction(el);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -76,28 +76,26 @@ async function updateNotificationCountWithCallback(callback: (timeout: number, n
|
|||
}
|
||||
|
||||
async function updateNotificationTable() {
|
||||
let notificationDiv = document.querySelector('#notification_div');
|
||||
if (notificationDiv) {
|
||||
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()}`);
|
||||
const notificationDiv = document.querySelector('#notification_div');
|
||||
if (!notificationDiv) return;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch notification table');
|
||||
}
|
||||
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()}`);
|
||||
|
||||
const data = await response.text();
|
||||
const el = createElementFromHTML(data);
|
||||
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);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch notification table');
|
||||
}
|
||||
|
||||
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 {createTippy} from '../modules/tippy.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 {performFetchActionTrigger} from './common-fetch-action.ts';
|
||||
|
||||
function initRepoDiffFileBox(el: HTMLElement) {
|
||||
// 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
|
||||
const respFileBoxesChildren = Array.from(respFileBoxes.children); // "children:HTMLCollection" will be empty after replaceWith
|
||||
document.querySelector('#diff-incomplete')!.replaceWith(...respFileBoxesChildren);
|
||||
for (const el of respFileBoxesChildren) window.htmx.process(el);
|
||||
onShowMoreFiles();
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
|
@ -204,7 +204,6 @@ function initRepoDiffShowMore() {
|
|||
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
|
||||
el.parentElement!.replaceWith(...respFileBodyChildren);
|
||||
for (const el of respFileBodyChildren) window.htmx.process(el);
|
||||
// FIXME: calling onShowMoreFiles is not quite right here.
|
||||
// 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.
|
||||
|
|
@ -251,8 +250,8 @@ async function onLocationHashChange() {
|
|||
const attrAutoLoadClicked = 'data-auto-load-clicked';
|
||||
if (expandButton.hasAttribute(attrAutoLoadClicked)) return;
|
||||
expandButton.setAttribute(attrAutoLoadClicked, 'true');
|
||||
expandButton.click();
|
||||
await sleep(500); // Wait for HTMX to load the content. FIXME: need to drop htmx in the future
|
||||
// trigger the fetch action to load the hidden comments, after loading, it will try to find the target element again
|
||||
await performFetchActionTrigger(expandButton, 'load');
|
||||
continue; // Try again to find the element
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,32 +6,60 @@ import {POST} from '../modules/fetch.ts';
|
|||
import {initDropzone} from './dropzone.ts';
|
||||
import {confirmModal} from './comp/ConfirmModal.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 {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) {
|
||||
const elTabMenu = elForm.querySelector('.repo-editor-menu')!;
|
||||
const elTabMenu = elForm.querySelector('.repo-editor-menu');
|
||||
if (!elTabMenu) return;
|
||||
fomanticQuery(elTabMenu.querySelectorAll('.item')).tab();
|
||||
|
||||
const elPreviewTab = elTabMenu.querySelector('a[data-tab="preview"]');
|
||||
const elPreviewPanel = elForm.querySelector('.tab[data-tab="preview"]');
|
||||
if (!elPreviewTab || !elPreviewPanel) return;
|
||||
const elTreePath = elForm.querySelector<HTMLInputElement>('input#tree_path');
|
||||
const elTextarea = elForm.querySelector<HTMLTextAreaElement>('.tab[data-tab="write"] textarea');
|
||||
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 () => {
|
||||
const elTreePath = elForm.querySelector<HTMLInputElement>('input#tree_path')!;
|
||||
const previewUrl = elPreviewTab.getAttribute('data-preview-url')!;
|
||||
const previewContextRef = elPreviewTab.getAttribute('data-preview-context-ref');
|
||||
let previewContext = `${previewContextRef}/${elTreePath.value}`;
|
||||
previewContext = previewContext.substring(0, previewContext.lastIndexOf('/'));
|
||||
// "preview context" is the request path directory of the file, the rendered links will be resolved based on this path
|
||||
// TODO: MARKUP-RENDER-CONTEXT: due to various hacky patches, this logic is unnecessarily complicated, see the backend
|
||||
const previewContext = dirname(`${repoLink}/src/${refSubUrl}/${pathEscapeSegments(elTreePath.value)}`);
|
||||
const formData = new FormData();
|
||||
formData.append('mode', 'file');
|
||||
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);
|
||||
const response = await POST(previewUrl, {data: formData});
|
||||
const data = await response.text();
|
||||
const resp = await POST(`${repoLink}/markup`, {data: formData});
|
||||
if (!resp.ok) {
|
||||
showErrorToast(`Failed to render preview: ${resp.status} ${resp.statusText}`);
|
||||
return;
|
||||
}
|
||||
const data = await resp.text();
|
||||
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() {
|
||||
|
|
@ -54,6 +82,8 @@ export function initRepoEditor() {
|
|||
// ATTENTION: two pages have this filename input
|
||||
// * new/edit file page: there is a code editor
|
||||
// * 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')!;
|
||||
if (!filenameInput) return;
|
||||
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
|
||||
// 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);
|
||||
window.htmx.process(newItem);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
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
|
||||
const newSidebar = doc.querySelector('.issue-content-right')!;
|
||||
this.elIssueSidebar.replaceWith(newSidebar);
|
||||
window.htmx.process(newSidebar);
|
||||
|
||||
// for the main content (left side), at the moment we only support handling known timeline items
|
||||
const newMainContent = doc.querySelector('.issue-content-left')!;
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ interface Window {
|
|||
},
|
||||
$: JQueryStatic,
|
||||
jQuery: JQueryStatic,
|
||||
htmx: typeof import('htmx.org').default,
|
||||
_globalHandlerErrors: Array<ErrorEvent & PromiseRejectionEvent> & {
|
||||
_inited: boolean,
|
||||
push: (e: ErrorEvent & PromiseRejectionEvent) => void | number,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,5 @@
|
|||
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.
|
||||
// To avoid breaking existing users and custom plugins, import jQuery globally without ES module.
|
||||
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 '../css/index.css';
|
||||
import type {HtmxResponseInfo} from 'htmx.org';
|
||||
import {showErrorToast} from './modules/toast.ts';
|
||||
|
||||
import {initDashboardRepoList} from './features/dashboard.ts';
|
||||
import {initGlobalCopyToClipboardListener} from './features/clipboard.ts';
|
||||
|
|
@ -173,15 +171,3 @@ const initDur = performance.now() - initStartTime;
|
|||
if (initDur > 500) {
|
||||
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
|
||||
// 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');
|
||||
import './globals.ts';
|
||||
|
||||
window.config = {
|
||||
appUrl: 'http://localhost:3000/',
|
||||
|
|
|
|||
Loading…
Reference in New Issue