@dignite/ng.site 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -2
- package/fesm2022/dignite-ng.site-config.mjs +87 -0
- package/fesm2022/dignite-ng.site-config.mjs.map +1 -0
- package/fesm2022/dignite-ng.site-content-editor.component-iFWDeYJ9.mjs +782 -0
- package/fesm2022/dignite-ng.site-content-editor.component-iFWDeYJ9.mjs.map +1 -0
- package/fesm2022/dignite-ng.site-content-types.component-D2jMSSq6.mjs +324 -0
- package/fesm2022/dignite-ng.site-content-types.component-D2jMSSq6.mjs.map +1 -0
- package/fesm2022/dignite-ng.site-contents.component-DKuel8w-.mjs +604 -0
- package/fesm2022/dignite-ng.site-contents.component-DKuel8w-.mjs.map +1 -0
- package/fesm2022/dignite-ng.site-dignite-ng.site-CsKxqjY9.mjs +2464 -0
- package/fesm2022/dignite-ng.site-dignite-ng.site-CsKxqjY9.mjs.map +1 -0
- package/fesm2022/dignite-ng.site-fields.component-By_YyfIx.mjs +409 -0
- package/fesm2022/dignite-ng.site-fields.component-By_YyfIx.mjs.map +1 -0
- package/fesm2022/dignite-ng.site-pages.component-S9BEgC4W.mjs +235 -0
- package/fesm2022/dignite-ng.site-pages.component-S9BEgC4W.mjs.map +1 -0
- package/fesm2022/dignite-ng.site.mjs +2 -0
- package/fesm2022/dignite-ng.site.mjs.map +1 -0
- package/package.json +48 -3
- package/types/dignite-ng.site-config.d.ts +14 -0
- package/types/dignite-ng.site.d.ts +1409 -0
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
import * as i4 from '@abp/ng.core';
|
|
2
|
+
import { LocalizationService, CoreModule } from '@abp/ng.core';
|
|
3
|
+
import { ToasterService, ThemeSharedModule } from '@abp/ng.theme.shared';
|
|
4
|
+
import * as i0 from '@angular/core';
|
|
5
|
+
import { inject, signal, ChangeDetectionStrategy, Component } from '@angular/core';
|
|
6
|
+
import * as i1 from '@angular/forms';
|
|
7
|
+
import { FormBuilder, Validators, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
|
8
|
+
import * as i2 from '@angular/router';
|
|
9
|
+
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
|
10
|
+
import { FlexFieldControlComponent } from '@dignite/ng.flex-fields';
|
|
11
|
+
import { forkJoin, of, switchMap, catchError } from 'rxjs';
|
|
12
|
+
import { C as ContentAdminService, c as ContentTypeAdminService, P as PageAdminService, S as SiteReferenceDataService, d as ContentStatus } from './dignite-ng.site-dignite-ng.site-CsKxqjY9.mjs';
|
|
13
|
+
import * as i3 from '@ngx-validate/core';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Maps a mounted flex field's own control errors to the same localized text, regardless of which
|
|
17
|
+
* field type produced them or how deep the control is nested (top-level, a Table column, a Matrix
|
|
18
|
+
* sub-field): every built-in field type only ever raises `required`/`min`/`max`/`maxlength`, since
|
|
19
|
+
* that's the full set `NumberControlComponent`/`TextControlComponent`/etc. push in `@dignite/ng.flex-fields`.
|
|
20
|
+
* Gated on `touched` so a fresh, still-empty required field doesn't show red before the user reaches it.
|
|
21
|
+
*/
|
|
22
|
+
function flexFieldErrorMessage(control, localization) {
|
|
23
|
+
const errors = control?.touched ? control.errors : null;
|
|
24
|
+
if (!errors) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (errors['required']) {
|
|
28
|
+
return localization.instant('AbpValidation::ThisFieldIsRequired');
|
|
29
|
+
}
|
|
30
|
+
if (errors['min']) {
|
|
31
|
+
return localization.instant('FlexFieldsSite::Validate:MinValue', String(errors['min'].min));
|
|
32
|
+
}
|
|
33
|
+
if (errors['max']) {
|
|
34
|
+
return localization.instant('FlexFieldsSite::Validate:MaxValue', String(errors['max'].max));
|
|
35
|
+
}
|
|
36
|
+
if (errors['maxlength']) {
|
|
37
|
+
return localization.instant('FlexFieldsSite::Validate:MaxLength', String(errors['maxlength'].requiredLength));
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const MAX_SLUG_LENGTH = 256;
|
|
43
|
+
const OPTIONAL_SLUG_TOKEN = '{slug?}';
|
|
44
|
+
const REQUIRED_SLUG_TOKEN = '{slug}';
|
|
45
|
+
/** Mirrors `SlugNormalizer.AllowedCharactersPattern` (`src/Dignite.Site.Domain.Shared/SlugNormalizer.cs`). */
|
|
46
|
+
const SLUG_PATTERN = /^[\p{L}\p{Nd}_-]*$/u;
|
|
47
|
+
/**
|
|
48
|
+
* Creating and editing one content, as a page of its own rather than a dialog.
|
|
49
|
+
*
|
|
50
|
+
* The two halves answer different questions and are sized accordingly: the left is the content itself -
|
|
51
|
+
* however many fields its content type defines, which can be a long form - and the right is the handful
|
|
52
|
+
* of system fields that place and schedule it. A dialog forced both into one scrolling column.
|
|
53
|
+
*
|
|
54
|
+
* Switching the content type rebuilds the left half, because the fields *are* the content type's
|
|
55
|
+
* definition. Values already typed under fields the new type does not have are dropped, which is the
|
|
56
|
+
* honest outcome: they have nowhere to be stored.
|
|
57
|
+
*
|
|
58
|
+
* **Deliberately no CKEditor theme bridge here.** This component used to carry a global
|
|
59
|
+
* `::ng-deep :root` block remapping CKEditor 5's `--ck-color-base-*` tokens onto the host theme -
|
|
60
|
+
* CKEditor ships one fixed light palette with no dark-mode switch, so the editor stayed light in a
|
|
61
|
+
* dark-themed host. `@dignite/ng.flex-fields-ckeditor` >= 10.0.0-rc.13 ships that bridge itself, with
|
|
62
|
+
* a longer fallback chain than this copy had (LeptonX token -> the Bootstrap 5.3 token every ABP
|
|
63
|
+
* Angular theme defines -> CKEditor's own stock literal) plus fixes this copy never had. Re-adding a
|
|
64
|
+
* copy here would not add anything and would fight it at equal `:root` specificity, decided by style
|
|
65
|
+
* injection order alone.
|
|
66
|
+
*/
|
|
67
|
+
class ContentEditorComponent {
|
|
68
|
+
constructor() {
|
|
69
|
+
this.contentService = inject(ContentAdminService);
|
|
70
|
+
this.contentTypeService = inject(ContentTypeAdminService);
|
|
71
|
+
this.pageService = inject(PageAdminService);
|
|
72
|
+
this.referenceData = inject(SiteReferenceDataService);
|
|
73
|
+
this.route = inject(ActivatedRoute);
|
|
74
|
+
this.router = inject(Router);
|
|
75
|
+
this.fb = inject(FormBuilder);
|
|
76
|
+
this.toaster = inject(ToasterService);
|
|
77
|
+
this.localization = inject(LocalizationService);
|
|
78
|
+
this.ContentStatus = ContentStatus;
|
|
79
|
+
this.statuses = [ContentStatus.Draft, ContentStatus.Published, ContentStatus.Archived];
|
|
80
|
+
this.contentId = this.route.snapshot.paramMap.get('id');
|
|
81
|
+
this.isEdit = !!this.contentId;
|
|
82
|
+
// State that changes in an HTTP callback is a signal, not a plain field: the host layout's ancestors
|
|
83
|
+
// (`abp-router-outlet`, the LeptonX layouts) are OnPush, and a plain-field write in a response
|
|
84
|
+
// callback never marks them for check, so the form would stay blank (or go stale) until some
|
|
85
|
+
// unrelated event happened to.
|
|
86
|
+
this.page = signal(undefined, /* @ts-ignore */
|
|
87
|
+
...(ngDevMode ? [{ debugName: "page" }] : /* istanbul ignore next */ []));
|
|
88
|
+
this.contentTypes = signal([], /* @ts-ignore */
|
|
89
|
+
...(ngDevMode ? [{ debugName: "contentTypes" }] : /* istanbul ignore next */ []));
|
|
90
|
+
this.languages = signal([], /* @ts-ignore */
|
|
91
|
+
...(ngDevMode ? [{ debugName: "languages" }] : /* istanbul ignore next */ []));
|
|
92
|
+
this.defaultLanguage = '';
|
|
93
|
+
/**
|
|
94
|
+
* Whether the page's own address (an empty slug) is already taken by another content, for the
|
|
95
|
+
* currently selected language - only meaningful, and only checked, when {@link slugState} is
|
|
96
|
+
* `'optional'`. Read by the template to decide whether the "use the page's own address" checkbox is
|
|
97
|
+
* offered at all.
|
|
98
|
+
*/
|
|
99
|
+
this.emptySlotTaken = signal(false, /* @ts-ignore */
|
|
100
|
+
...(ngDevMode ? [{ debugName: "emptySlotTaken" }] : /* istanbul ignore next */ []));
|
|
101
|
+
this.fieldsById = new Map();
|
|
102
|
+
this.arrangementByContentType = new Map();
|
|
103
|
+
/**
|
|
104
|
+
* The current content type's fields, as stable `FlexFieldValue`s. Rebuilt only when the content type
|
|
105
|
+
* changes - `ff-flex-field-control` compares this input by object reference, and handing it a freshly
|
|
106
|
+
* allocated array on every change-detection pass would make it tear down and recreate its dynamic
|
|
107
|
+
* child forever (`NG0103`).
|
|
108
|
+
*/
|
|
109
|
+
this.fields = signal([], /* @ts-ignore */
|
|
110
|
+
...(ngDevMode ? [{ debugName: "fields" }] : /* istanbul ignore next */ []));
|
|
111
|
+
this.isBusy = signal(false, /* @ts-ignore */
|
|
112
|
+
...(ngDevMode ? [{ debugName: "isBusy" }] : /* istanbul ignore next */ []));
|
|
113
|
+
this.isLoading = signal(true, /* @ts-ignore */
|
|
114
|
+
...(ngDevMode ? [{ debugName: "isLoading" }] : /* istanbul ignore next */ []));
|
|
115
|
+
const pageId = this.route.snapshot.queryParamMap.get('pageId');
|
|
116
|
+
forkJoin({
|
|
117
|
+
schema: this.referenceData.getSchema(),
|
|
118
|
+
fieldsById: this.referenceData.getFieldsById(),
|
|
119
|
+
content: this.contentId ? this.contentService.get(this.contentId) : of(undefined),
|
|
120
|
+
})
|
|
121
|
+
.pipe(switchMap(loaded => {
|
|
122
|
+
const languages = loaded.schema.enabledLanguages ?? [];
|
|
123
|
+
this.languages.set(languages);
|
|
124
|
+
this.defaultLanguage = loaded.schema.defaultLanguage ?? languages[0] ?? '';
|
|
125
|
+
this.fieldsById = loaded.fieldsById;
|
|
126
|
+
this.content = loaded.content;
|
|
127
|
+
const resolvedPageId = loaded.content?.pageId ?? pageId ?? '';
|
|
128
|
+
return forkJoin({
|
|
129
|
+
page: resolvedPageId ? this.pageService.get(resolvedPageId) : of(undefined),
|
|
130
|
+
contentTypes: resolvedPageId
|
|
131
|
+
? this.contentTypeService.getListByPage(resolvedPageId)
|
|
132
|
+
: of({ items: [] }),
|
|
133
|
+
});
|
|
134
|
+
}))
|
|
135
|
+
.subscribe(({ page, contentTypes }) => {
|
|
136
|
+
this.page.set(page);
|
|
137
|
+
this.contentTypes.set(contentTypes.items ?? []);
|
|
138
|
+
this.rebuildArrangements();
|
|
139
|
+
this.form = this.buildForm();
|
|
140
|
+
this.rebuildFields();
|
|
141
|
+
this.isLoading.set(false);
|
|
142
|
+
this.refreshEmptySlotAvailability();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
get hasMultipleContentTypes() {
|
|
146
|
+
return this.contentTypes().length > 1;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* What this page's route (总体设计 §3.3) says about the slug beneath it - the front-end mirror of
|
|
150
|
+
* `PageRoute.HasSlug`/`PageRoute.IsSlugOptional`. `'forbidden'`: no `{slug}`/`{slug?}` at all, so this
|
|
151
|
+
* page has nothing beneath it but its own address. `'required'`: `{slug}` or `{slug:REGEX}`, so every
|
|
152
|
+
* content needs one. `'optional'`: `{slug?}`, so an empty slug is also allowed, subject to
|
|
153
|
+
* {@link emptySlotTaken}.
|
|
154
|
+
*/
|
|
155
|
+
get slugState() {
|
|
156
|
+
const route = this.page()?.route ?? '';
|
|
157
|
+
if (route.includes(OPTIONAL_SLUG_TOKEN)) {
|
|
158
|
+
return 'optional';
|
|
159
|
+
}
|
|
160
|
+
return route.includes(REQUIRED_SLUG_TOKEN) || this.hasSlugWithRegex(route) ? 'required' : 'forbidden';
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Whether `route` carries `{slug:REGEX}` - a slug placeholder with a regex and no format, the one
|
|
164
|
+
* decorated form `PageRoute.HasSlug` recognizes. Whether a given slug matches that regex is left to the
|
|
165
|
+
* server (`PageRoute.IsSlugAllowed`): a .NET pattern does not always mean the same thing in JavaScript.
|
|
166
|
+
*/
|
|
167
|
+
hasSlugWithRegex(route) {
|
|
168
|
+
for (let i = route.indexOf('{'); i >= 0; i = route.indexOf('{', i + 1)) {
|
|
169
|
+
const placeholder = this.readPlaceholder(route, i);
|
|
170
|
+
if (placeholder && placeholder.name.toLowerCase() === 'slug' && placeholder.format === null) {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
if (placeholder) {
|
|
174
|
+
i = placeholder.end - 1;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
get usePageOwnAddress() {
|
|
180
|
+
return !!this.form?.get('usePageOwnAddress')?.value;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The address this content will actually be served at, recomputed on every read so the template can
|
|
184
|
+
* bind it directly and have it track the slug field as the user types. An approximation, not the
|
|
185
|
+
* source of truth: {@link resolvePlaceholder} resolves every placeholder generically off the form and
|
|
186
|
+
* the loaded content, falling back to the raw token when it cannot - `Page.BuildContentPath` on the
|
|
187
|
+
* server is what actually decides the URL.
|
|
188
|
+
*
|
|
189
|
+
* The empty-slug branch mirrors `Page.BuildContentPath` itself, not a naming convention: an empty
|
|
190
|
+
* slug is short-circuited to the page's own path there regardless of what the rest of the route
|
|
191
|
+
* contains, so the preview has to do the same rather than run it through the placeholder replacement
|
|
192
|
+
* below.
|
|
193
|
+
*/
|
|
194
|
+
get slugPreview() {
|
|
195
|
+
const route = this.page()?.route;
|
|
196
|
+
if (!route) {
|
|
197
|
+
return '';
|
|
198
|
+
}
|
|
199
|
+
const slug = (this.form?.get('slug')?.value ?? '').trim();
|
|
200
|
+
const path = slug ? this.buildPreviewPath(route) : this.pageOwnPath(route);
|
|
201
|
+
return this.applyCulturePrefix(path);
|
|
202
|
+
}
|
|
203
|
+
get isDraft() {
|
|
204
|
+
return this.form?.getRawValue().status === ContentStatus.Draft;
|
|
205
|
+
}
|
|
206
|
+
statusKeyOf(status) {
|
|
207
|
+
return `Site::Enum:ContentStatus:${ContentStatus[status]}`;
|
|
208
|
+
}
|
|
209
|
+
fieldErrorMessage(fieldValue) {
|
|
210
|
+
return flexFieldErrorMessage(this.form?.get('flexFields')?.get(fieldValue.field.name), this.localization);
|
|
211
|
+
}
|
|
212
|
+
rebuildArrangements() {
|
|
213
|
+
this.arrangementByContentType = new Map(this.contentTypes().map(contentType => [
|
|
214
|
+
contentType.id,
|
|
215
|
+
(contentType.fields ?? [])
|
|
216
|
+
.map(entry => {
|
|
217
|
+
const definition = entry.fieldId ? this.fieldsById.get(entry.fieldId) : undefined;
|
|
218
|
+
if (!definition) {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
const fieldData = {
|
|
222
|
+
id: definition.id,
|
|
223
|
+
name: definition.name,
|
|
224
|
+
displayName: entry.displayName || definition.displayName,
|
|
225
|
+
description: definition.description ?? undefined,
|
|
226
|
+
fieldTypeName: definition.fieldTypeName,
|
|
227
|
+
configuration: (definition.configuration ?? {}),
|
|
228
|
+
};
|
|
229
|
+
return {
|
|
230
|
+
fieldId: definition.id,
|
|
231
|
+
name: definition.name,
|
|
232
|
+
displayName: fieldData.displayName,
|
|
233
|
+
fieldTypeName: definition.fieldTypeName,
|
|
234
|
+
required: !!entry.required,
|
|
235
|
+
searchable: !!entry.searchable,
|
|
236
|
+
showInList: !!entry.showInList,
|
|
237
|
+
order: entry.order ?? 0,
|
|
238
|
+
fieldData,
|
|
239
|
+
};
|
|
240
|
+
})
|
|
241
|
+
.filter((field) => !!field)
|
|
242
|
+
.sort((a, b) => a.order - b.order),
|
|
243
|
+
]));
|
|
244
|
+
}
|
|
245
|
+
/** Called on load and whenever the content type changes. See {@link fields}. */
|
|
246
|
+
rebuildFields() {
|
|
247
|
+
const contentTypeId = this.form?.get('contentTypeId')?.value;
|
|
248
|
+
const arranged = this.arrangementByContentType.get(contentTypeId ?? '') ?? [];
|
|
249
|
+
const values = this.content?.fieldValues ?? {};
|
|
250
|
+
this.fields.set(arranged.map(field => ({
|
|
251
|
+
field: field.fieldData,
|
|
252
|
+
required: field.required,
|
|
253
|
+
searchable: field.searchable,
|
|
254
|
+
value: values[field.name],
|
|
255
|
+
})));
|
|
256
|
+
}
|
|
257
|
+
onContentTypeChange() {
|
|
258
|
+
// The value bag is keyed by field name, so controls for the old type's fields have to go before the
|
|
259
|
+
// new type's are registered - otherwise the payload carries both.
|
|
260
|
+
this.form?.setControl('flexFields', this.fb.group({}));
|
|
261
|
+
this.rebuildFields();
|
|
262
|
+
}
|
|
263
|
+
onStatusChange() {
|
|
264
|
+
this.applyPublishTimeAvailability();
|
|
265
|
+
}
|
|
266
|
+
onCultureNameChange() {
|
|
267
|
+
this.refreshEmptySlotAvailability();
|
|
268
|
+
}
|
|
269
|
+
onUsePageOwnAddressChange() {
|
|
270
|
+
this.applySlugAvailability();
|
|
271
|
+
}
|
|
272
|
+
save() {
|
|
273
|
+
if (!this.form || this.form.invalid || this.isBusy()) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
this.isBusy.set(true);
|
|
277
|
+
const value = this.form.getRawValue();
|
|
278
|
+
const fieldValues = (this.form.get('flexFields')?.getRawValue() ?? {});
|
|
279
|
+
const slug = value.slug ?? '';
|
|
280
|
+
const request$ = this.content
|
|
281
|
+
? this.contentService.update(this.content.id, {
|
|
282
|
+
contentTypeId: value.contentTypeId,
|
|
283
|
+
slug,
|
|
284
|
+
publishTime: value.publishTime,
|
|
285
|
+
status: value.status,
|
|
286
|
+
fieldValues,
|
|
287
|
+
})
|
|
288
|
+
: this.contentService.create({
|
|
289
|
+
contentTypeId: value.contentTypeId,
|
|
290
|
+
cultureName: value.cultureName,
|
|
291
|
+
slug,
|
|
292
|
+
publishTime: value.publishTime,
|
|
293
|
+
status: value.status,
|
|
294
|
+
fieldValues,
|
|
295
|
+
});
|
|
296
|
+
request$.subscribe({
|
|
297
|
+
next: () => {
|
|
298
|
+
this.isBusy.set(false);
|
|
299
|
+
this.toaster.success('AbpUi::SavedSuccessfully');
|
|
300
|
+
this.backToList();
|
|
301
|
+
},
|
|
302
|
+
error: () => this.isBusy.set(false),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
backToList() {
|
|
306
|
+
this.router.navigate(['/site/contents']);
|
|
307
|
+
}
|
|
308
|
+
buildForm() {
|
|
309
|
+
const content = this.content;
|
|
310
|
+
const form = this.fb.group({
|
|
311
|
+
contentTypeId: [
|
|
312
|
+
content?.contentTypeId ?? this.contentTypes()[0]?.id ?? '',
|
|
313
|
+
Validators.required,
|
|
314
|
+
],
|
|
315
|
+
cultureName: [
|
|
316
|
+
{
|
|
317
|
+
value: content?.cultureName ??
|
|
318
|
+
this.route.snapshot.queryParamMap.get('cultureName') ??
|
|
319
|
+
this.languages()[0] ??
|
|
320
|
+
'',
|
|
321
|
+
// Fixed after creation: the language is half the natural key that groups a content's
|
|
322
|
+
// translations, so changing it would silently move the content to another translation set.
|
|
323
|
+
disabled: !!content,
|
|
324
|
+
},
|
|
325
|
+
Validators.required,
|
|
326
|
+
],
|
|
327
|
+
// Editing a content that is already the page's own (an empty slug) starts the checkbox checked -
|
|
328
|
+
// otherwise applySlugAvailability would immediately overwrite its empty slug with a forced-required
|
|
329
|
+
// value the moment the form builds.
|
|
330
|
+
usePageOwnAddress: [!!content && !content.slug],
|
|
331
|
+
slug: [content?.slug ?? '', [Validators.maxLength(MAX_SLUG_LENGTH), Validators.pattern(SLUG_PATTERN)]],
|
|
332
|
+
publishTime: [this.toLocalDateTime(content?.publishTime), Validators.required],
|
|
333
|
+
status: [content?.status ?? ContentStatus.Published],
|
|
334
|
+
flexFields: this.fb.group({}),
|
|
335
|
+
});
|
|
336
|
+
this.form = form;
|
|
337
|
+
this.applySlugAvailability();
|
|
338
|
+
this.applyPublishTimeAvailability();
|
|
339
|
+
return form;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Enables/disables and (in)validates the `slug` control from {@link slugState} - and, in the
|
|
343
|
+
* `'optional'` case, from whether the "use the page's own address" checkbox is ticked. The same
|
|
344
|
+
* three-way split `ContentManager.CheckSlugRequirementAsync` enforces server-side; this only decides
|
|
345
|
+
* what the form looks like before that ever runs.
|
|
346
|
+
*/
|
|
347
|
+
applySlugAvailability() {
|
|
348
|
+
const slug = this.form?.get('slug');
|
|
349
|
+
if (!slug) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const forceOwnAddress = this.slugState === 'forbidden' ||
|
|
353
|
+
(this.slugState === 'optional' && this.usePageOwnAddress);
|
|
354
|
+
if (forceOwnAddress) {
|
|
355
|
+
slug.setValue('');
|
|
356
|
+
slug.clearValidators();
|
|
357
|
+
slug.disable();
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
slug.enable();
|
|
361
|
+
slug.setValidators([
|
|
362
|
+
Validators.required,
|
|
363
|
+
Validators.maxLength(MAX_SLUG_LENGTH),
|
|
364
|
+
Validators.pattern(SLUG_PATTERN),
|
|
365
|
+
]);
|
|
366
|
+
}
|
|
367
|
+
slug.updateValueAndValidity();
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Looks up whether this page's own address (an empty slug) is already taken for the selected
|
|
371
|
+
* language, so the "use the page's own address" option can be withheld before the editor ever submits
|
|
372
|
+
* and hits `Site:040001`. A no-op outside the `'optional'` state, where the question does not arise.
|
|
373
|
+
*
|
|
374
|
+
* Goes through `getList` rather than the more direct `findBySlug`, which exists for exactly this
|
|
375
|
+
* lookup - `IContentAdminAppService.FindBySlugAsync(pageId, cultureName, "")` is the same query
|
|
376
|
+
* `SiteRouteResolver` uses to resolve a bare page match. But ABP's `RestService.getParams` silently
|
|
377
|
+
* drops any query parameter whose value is an empty string, so a `slug` of `""` never reaches the
|
|
378
|
+
* request at all; the server then sees a *missing* parameter, not an intentionally empty one, and
|
|
379
|
+
* rejects it exactly as `Should_Reject_A_Null_Slug` says it must. Listing this page's contents for the
|
|
380
|
+
* language and filtering for an empty slug client-side sidesteps that without weakening the server's
|
|
381
|
+
* null-vs-empty distinction.
|
|
382
|
+
*/
|
|
383
|
+
refreshEmptySlotAvailability() {
|
|
384
|
+
const pageId = this.page()?.id;
|
|
385
|
+
if (this.slugState !== 'optional' || !pageId) {
|
|
386
|
+
this.emptySlotTaken.set(false);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const cultureName = this.form?.get('cultureName')?.value;
|
|
390
|
+
if (!cultureName) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
this.contentService
|
|
394
|
+
.getList({ pageId, cultureName, maxResultCount: 1000 })
|
|
395
|
+
.pipe(catchError(() => of({ items: [], totalCount: 0 })))
|
|
396
|
+
.subscribe(result => {
|
|
397
|
+
const existing = (result.items ?? []).find(item => item.slug === '');
|
|
398
|
+
const taken = !!existing && existing.id !== this.content?.id;
|
|
399
|
+
this.emptySlotTaken.set(taken);
|
|
400
|
+
// The slot this checkbox promised is gone (e.g. the language was switched to one where it is
|
|
401
|
+
// already taken) - un-tick it rather than leave a checked box the next save would reject.
|
|
402
|
+
if (taken && this.usePageOwnAddress) {
|
|
403
|
+
this.form?.get('usePageOwnAddress')?.setValue(false);
|
|
404
|
+
this.applySlugAvailability();
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* A draft is never public, so a publish time on one is not a schedule anything acts on - the server
|
|
410
|
+
* rejects the combination outright (`Site:040003`). Scheduling is Published plus a future time.
|
|
411
|
+
*/
|
|
412
|
+
applyPublishTimeAvailability() {
|
|
413
|
+
const publishTime = this.form?.get('publishTime');
|
|
414
|
+
const status = this.form?.get('status')?.value;
|
|
415
|
+
if (!publishTime) {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (status === ContentStatus.Draft) {
|
|
419
|
+
publishTime.setValue(this.toLocalDateTime());
|
|
420
|
+
publishTime.disable();
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
publishTime.enable();
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
/** `<input type="datetime-local">` wants `yyyy-MM-ddTHH:mm`, not an ISO string with a zone. */
|
|
427
|
+
toLocalDateTime(value) {
|
|
428
|
+
const date = value ? new Date(value) : new Date();
|
|
429
|
+
const offset = date.getTimezoneOffset() * 60_000;
|
|
430
|
+
return new Date(date.getTime() - offset).toISOString().slice(0, 16);
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Mirrors `SiteUrlContext.ApplyCulturePrefix` (总体设计 §5.5): every enabled language carries a
|
|
434
|
+
* `/cultureName` prefix except the default one, which is served unprefixed. `cultureName` here is
|
|
435
|
+
* already one of `languages` (or the loaded content's own, already-normalized value), so - unlike the
|
|
436
|
+
* server, which normalizes through `CultureNameNormalizer` first - a plain equality check against
|
|
437
|
+
* {@link defaultLanguage} is enough to tell the two apart.
|
|
438
|
+
*/
|
|
439
|
+
applyCulturePrefix(path) {
|
|
440
|
+
const cultureName = this.form?.get('cultureName')?.value;
|
|
441
|
+
if (!cultureName || cultureName === this.defaultLanguage) {
|
|
442
|
+
return path;
|
|
443
|
+
}
|
|
444
|
+
return path === '/' ? `/${cultureName}` : `/${cultureName}${path}`;
|
|
445
|
+
}
|
|
446
|
+
/** Mirrors `PageRoute.GetPath`: everything up to the segment that first carries a placeholder. */
|
|
447
|
+
pageOwnPath(route) {
|
|
448
|
+
const index = route.indexOf('{');
|
|
449
|
+
if (index < 0) {
|
|
450
|
+
return route;
|
|
451
|
+
}
|
|
452
|
+
const boundary = route.lastIndexOf('/', index);
|
|
453
|
+
return boundary <= 0 ? '/' : route.slice(0, boundary);
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* A rough, client-side mirror of `PageRoute.Build` - see {@link slugPreview}'s remarks. Every
|
|
457
|
+
* placeholder, `{slug}` included, goes through the same generic {@link resolvePlaceholder} - no
|
|
458
|
+
* per-name branch here, deliberately: the server does not have one either, it reflects over `Content`
|
|
459
|
+
* (`Page.ResolveFieldValue`), and a name-by-name `if` ladder here would just be that same mistake
|
|
460
|
+
* moved to TypeScript instead of removed.
|
|
461
|
+
* <p>
|
|
462
|
+
* Placeholders are located with {@link readPlaceholder}'s brace-depth-aware scan, not a single regex -
|
|
463
|
+
* a `/\{...\}/` pattern cannot tell a placeholder's own closing brace apart from a balanced one inside
|
|
464
|
+
* its `:REGEX` segment (a quantifier like `\d{4}`, e.g. in `{publishTime:yyyy-MM:^\d{4}-(0[1-9]|1[0-2])$}`),
|
|
465
|
+
* the same reason `PageRoute.TryParseTemplate` on the server had to stop using ABP's own tokenizer.
|
|
466
|
+
*/
|
|
467
|
+
buildPreviewPath(route) {
|
|
468
|
+
const canonical = route.replace(OPTIONAL_SLUG_TOKEN, REQUIRED_SLUG_TOKEN);
|
|
469
|
+
let result = '';
|
|
470
|
+
let i = 0;
|
|
471
|
+
while (i < canonical.length) {
|
|
472
|
+
if (canonical[i] !== '{') {
|
|
473
|
+
result += canonical[i];
|
|
474
|
+
i++;
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
const placeholder = this.readPlaceholder(canonical, i);
|
|
478
|
+
if (!placeholder) {
|
|
479
|
+
// Not a well-formed placeholder start - a route that reaches this component was already
|
|
480
|
+
// validated by `PageRoute.IsValid` when it was saved, so this is only ever a defensive fallback:
|
|
481
|
+
// treat '{' as a literal character rather than throwing out of what is meant to be a best-effort
|
|
482
|
+
// preview.
|
|
483
|
+
result += canonical[i];
|
|
484
|
+
i++;
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
result += this.resolvePlaceholder(placeholder.name, placeholder.format) ?? placeholder.token;
|
|
488
|
+
i = placeholder.end;
|
|
489
|
+
}
|
|
490
|
+
return result;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Reads one `{name}` / `{name:FORMAT}` / `{name:REGEX}` / `{name:FORMAT:REGEX}` placeholder starting at
|
|
494
|
+
* `route[start]` (must be `'{'`), mirroring `PageRoute.TryParseTemplate`'s single-placeholder case
|
|
495
|
+
* (`src/Dignite.Site.Domain.Shared/Pages/PageRoute.cs`): brace depth is tracked once inside the
|
|
496
|
+
* `:`-segment so an embedded, balanced `{`/`}` does not get mistaken for the placeholder's own closing
|
|
497
|
+
* brace, and a backslash escapes the next character so an explicitly-escaped `\{`/`\}` never perturbs
|
|
498
|
+
* the count either. Returns `null` for anything that does not parse as a well-formed placeholder -
|
|
499
|
+
* {@link buildPreviewPath} treats that as a literal `{` rather than failing the whole preview.
|
|
500
|
+
* <p>
|
|
501
|
+
* Only `name` and `format` are extracted, never the regex itself - a placeholder's regex constrains
|
|
502
|
+
* what may be read back out of a request path, and has nothing to say about a value being written into
|
|
503
|
+
* one, the same reason `PageRoute.Build` on the server discards it too (see that method's remarks).
|
|
504
|
+
*/
|
|
505
|
+
readPlaceholder(route, start) {
|
|
506
|
+
let i = start + 1; // consume '{'
|
|
507
|
+
const nameStart = i;
|
|
508
|
+
// Mirrors `PageRoute.IsNameStartChar`/`IsNameChar`: every `IdentifierName` a field can have, plus
|
|
509
|
+
// uppercase for `Content`'s camel-cased system properties (`publishTime`).
|
|
510
|
+
if (i >= route.length || !/[a-zA-Z0-9]/.test(route[i])) {
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
i++;
|
|
514
|
+
while (i < route.length && /[a-zA-Z0-9_-]/.test(route[i])) {
|
|
515
|
+
i++;
|
|
516
|
+
}
|
|
517
|
+
const name = route.slice(nameStart, i);
|
|
518
|
+
if (i >= route.length) {
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
if (route[i] === '}') {
|
|
522
|
+
return { token: route.slice(start, i + 1), end: i + 1, name, format: null };
|
|
523
|
+
}
|
|
524
|
+
if (route[i] !== ':') {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
i++; // consume ':'
|
|
528
|
+
const innerStart = i;
|
|
529
|
+
let depth = 0;
|
|
530
|
+
let closed = false;
|
|
531
|
+
while (i < route.length) {
|
|
532
|
+
const c = route[i];
|
|
533
|
+
if (c === '\\' && i + 1 < route.length) {
|
|
534
|
+
i += 2;
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if (c === '{') {
|
|
538
|
+
depth++;
|
|
539
|
+
}
|
|
540
|
+
else if (c === '}') {
|
|
541
|
+
if (depth === 0) {
|
|
542
|
+
closed = true;
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
depth--;
|
|
546
|
+
}
|
|
547
|
+
i++;
|
|
548
|
+
}
|
|
549
|
+
if (!closed) {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
const inner = route.slice(innerStart, i);
|
|
553
|
+
const end = i + 1; // consume the closing '}'
|
|
554
|
+
return { token: route.slice(start, end), end, name, format: this.disambiguateFormat(inner) };
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Mirrors `PageRoute.TryDisambiguate`, reduced to the half a preview needs: a placeholder's already
|
|
558
|
+
* brace-depth-isolated inner text (everything after the first `:`) splits into a `:FORMAT` prefix plus a
|
|
559
|
+
* regex remainder when the prefix is format-shaped (letters/digits/`.`/`_`/`-` only - a real format
|
|
560
|
+
* string, by construction, can never also look like a regex worth compiling, which is what keeps every
|
|
561
|
+
* `{name:FORMAT}` route classified exactly as it always was); otherwise the inner text is a bare format
|
|
562
|
+
* on its own if it matches that shape whole, or else it is a regex with no format at all.
|
|
563
|
+
*/
|
|
564
|
+
disambiguateFormat(inner) {
|
|
565
|
+
const formatCharacters = /^[a-zA-Z0-9_.-]+$/;
|
|
566
|
+
const colon = inner.indexOf(':');
|
|
567
|
+
if (colon >= 0) {
|
|
568
|
+
const candidate = inner.slice(0, colon);
|
|
569
|
+
if (candidate && formatCharacters.test(candidate)) {
|
|
570
|
+
return candidate;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return formatCharacters.test(inner) ? inner : null;
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Resolves one placeholder's preview text purely from *where* `name` is actually found, never from
|
|
577
|
+
* what it is called. Angular forms have no runtime type to reflect over the way `Content` does server
|
|
578
|
+
* side - `ContentDto` is a TypeScript interface, erased at compile time - so the nearest equivalent is
|
|
579
|
+
* a generic, case-insensitive walk of what is actually on the form and the loaded content, checked in
|
|
580
|
+
* the same order the server would find them: a real property first, `FlexFields` second.
|
|
581
|
+
* 1. A top-level form control - every system field the editor can change lives here (`cultureName`,
|
|
582
|
+
* `status`, `publishTime`, `contentTypeId`, `slug`...), whatever it happens to be named.
|
|
583
|
+
* 2. A control inside `flexFields` - a content type's own business fields.
|
|
584
|
+
* 3. A property already on the loaded `this.content` DTO - covers the read-only audit trail
|
|
585
|
+
* (`id`, `creationTime`, ...) `FullAuditedEntityDto` carries but no form control exists for.
|
|
586
|
+
* Naturally empty until this content has actually been saved once - which is honest, since the
|
|
587
|
+
* server has nothing to reflect there yet either.
|
|
588
|
+
*
|
|
589
|
+
* One deliberate, named exception to "no name-based branching": {@link usePageOwnAddress} is a UI-only
|
|
590
|
+
* control with no server-side counterpart, and step 1 cannot tell that apart from a real field by
|
|
591
|
+
* shape alone - a route that actually referenced `{usePageOwnAddress}` would resolve to "true"/"false"
|
|
592
|
+
* here. Excluding it by name would be exactly the kind of branch this method exists to avoid, and the
|
|
593
|
+
* failure mode is harmless: `Page.BuildContentPath` has no such property either, so a route that made
|
|
594
|
+
* this mistake for real still fails loudly server-side the first time a URL is actually built from it.
|
|
595
|
+
*/
|
|
596
|
+
resolvePlaceholder(name, format) {
|
|
597
|
+
const raw = this.findValue(name);
|
|
598
|
+
if (raw === null || raw === undefined || raw === '') {
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
if (format) {
|
|
602
|
+
const formatted = this.tryFormatByShape(raw, format);
|
|
603
|
+
if (formatted !== null) {
|
|
604
|
+
return formatted;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return String(raw);
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Applies `format` to `raw` purely from what `raw` looks like, never from which placeholder produced
|
|
611
|
+
* it - the same dispatch-by-shape rule {@link resolvePlaceholder} follows, extended to every shape
|
|
612
|
+
* this preview knows how to format. Mirrors `Page.FormatFieldValue`'s `value is IFormattable` check,
|
|
613
|
+
* just done by sniffing a form value's shape with a regex instead of asking the CLR for its runtime
|
|
614
|
+
* type - there is no such type to ask here, `raw` is already unwrapped to a plain JS value by the time
|
|
615
|
+
* it reaches this method.
|
|
616
|
+
* <p>
|
|
617
|
+
* Each shape is tried in turn; the first one whose regex matches `raw` owns the answer from there,
|
|
618
|
+
* whether or not it actually recognizes `format` - a date-shaped value with a nonsense format is a
|
|
619
|
+
* date that failed to format, not a number or a Guid that happened to also look like one. Recognizing
|
|
620
|
+
* the shape but not the specific `format` asked of it (a Guid's `X`, a numeric format outside
|
|
621
|
+
* {@link formatNumber}'s subset) returns null, same as a shape nothing here recognizes at all - both
|
|
622
|
+
* fall back to the unformatted value in {@link resolvePlaceholder}, deliberately: this is a preview,
|
|
623
|
+
* and a plausible but possibly-wrong guess at an unimplemented format would be worse than showing the
|
|
624
|
+
* plain value.
|
|
625
|
+
*/
|
|
626
|
+
tryFormatByShape(raw, format) {
|
|
627
|
+
const date = this.tryParseDate(raw);
|
|
628
|
+
if (date) {
|
|
629
|
+
return this.formatDate(date, format);
|
|
630
|
+
}
|
|
631
|
+
const guid = this.tryParseGuid(raw);
|
|
632
|
+
if (guid) {
|
|
633
|
+
return this.formatGuid(guid, format);
|
|
634
|
+
}
|
|
635
|
+
const number = this.tryParseNumber(raw);
|
|
636
|
+
if (number !== null) {
|
|
637
|
+
return this.formatNumber(number, format);
|
|
638
|
+
}
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
findValue(name) {
|
|
642
|
+
const lowerName = name.toLowerCase();
|
|
643
|
+
const topLevelControls = this.form?.controls;
|
|
644
|
+
const topLevelKey = topLevelControls && Object.keys(topLevelControls).find(key => key.toLowerCase() === lowerName);
|
|
645
|
+
if (topLevelKey) {
|
|
646
|
+
return this.form.get(topLevelKey).value;
|
|
647
|
+
}
|
|
648
|
+
const flexFields = this.form?.get('flexFields');
|
|
649
|
+
if (flexFields instanceof FormGroup) {
|
|
650
|
+
const flexKey = Object.keys(flexFields.controls).find(key => key.toLowerCase() === lowerName);
|
|
651
|
+
if (flexKey) {
|
|
652
|
+
return flexFields.get(flexKey).value;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
if (this.content) {
|
|
656
|
+
const contentRecord = this.content;
|
|
657
|
+
const contentKey = Object.keys(contentRecord).find(key => key.toLowerCase() === lowerName);
|
|
658
|
+
if (contentKey !== undefined) {
|
|
659
|
+
return contentRecord[contentKey];
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return undefined;
|
|
663
|
+
}
|
|
664
|
+
/** Whether `value` is shaped like a `datetime-local` input's own output (`yyyy-MM-ddTHH:mm`). */
|
|
665
|
+
tryParseDate(value) {
|
|
666
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)) {
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
const date = new Date(value);
|
|
670
|
+
return isNaN(date.getTime()) ? null : date;
|
|
671
|
+
}
|
|
672
|
+
/** Only the three specifiers a `:FORMAT` suffix may actually contain (总体设计 §3.3) - no '/' among them. */
|
|
673
|
+
formatDate(date, format) {
|
|
674
|
+
return format
|
|
675
|
+
.replace(/yyyy/g, String(date.getFullYear()))
|
|
676
|
+
.replace(/yy/g, String(date.getFullYear()).slice(-2))
|
|
677
|
+
.replace(/MM/g, String(date.getMonth() + 1).padStart(2, '0'))
|
|
678
|
+
.replace(/dd/g, String(date.getDate()).padStart(2, '0'));
|
|
679
|
+
}
|
|
680
|
+
/** Whether `value` is shaped like the canonical, hyphenated form of a Guid. */
|
|
681
|
+
tryParseGuid(value) {
|
|
682
|
+
return typeof value === 'string' &&
|
|
683
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
|
|
684
|
+
? value
|
|
685
|
+
: null;
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* `Guid.ToString(format)`'s four straightforward specifiers - N (no separators), D (hyphenated, the
|
|
689
|
+
* shape already checked by {@link tryParseGuid}), B (braced) and P (parenthesized). `X` deliberately
|
|
690
|
+
* not implemented: it renders as a C#-syntax initializer list, not a URL-shaped string, and nothing
|
|
691
|
+
* about a route makes that a plausible thing to have ever asked for.
|
|
692
|
+
*/
|
|
693
|
+
formatGuid(guid, format) {
|
|
694
|
+
const digits = guid.replace(/-/g, '').toLowerCase();
|
|
695
|
+
const dashed = `${digits.slice(0, 8)}-${digits.slice(8, 12)}-${digits.slice(12, 16)}-${digits.slice(16, 20)}-${digits.slice(20)}`;
|
|
696
|
+
switch (format.toUpperCase()) {
|
|
697
|
+
case 'N':
|
|
698
|
+
return digits;
|
|
699
|
+
case 'D':
|
|
700
|
+
return dashed;
|
|
701
|
+
case 'B':
|
|
702
|
+
return `{${dashed}}`;
|
|
703
|
+
case 'P':
|
|
704
|
+
return `(${dashed})`;
|
|
705
|
+
default:
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/** Whether `value` is shaped like a plain, optionally negative, optionally decimal number. */
|
|
710
|
+
tryParseNumber(value) {
|
|
711
|
+
if (typeof value === 'number' && isFinite(value)) {
|
|
712
|
+
return value;
|
|
713
|
+
}
|
|
714
|
+
return typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value) ? Number(value) : null;
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* A representative slice of .NET's numeric format strings - the ones a URL path segment plausibly
|
|
718
|
+
* asks for. Custom zero-padding (`"0000"`, unchanged from before) plus four standard specifiers, each
|
|
719
|
+
* with an optional 1-2 digit precision: `D` (zero-padded, integers only - the standard-format spelling
|
|
720
|
+
* of the same thing `"0000"` does), `X`/`x` (hexadecimal, integers only, letter case controls digit
|
|
721
|
+
* case, negative not attempted - see {@link formatHex}'s remarks), `F` (fixed-point) and `N`
|
|
722
|
+
* (fixed-point with thousands grouping). Deliberately not `C`/`P` (currency/percent): both put a
|
|
723
|
+
* character that means something else in a URL - a locale-dependent currency symbol, or `%`, the URL
|
|
724
|
+
* escape character itself - into the path, which nothing about routing would ever actually want. Not
|
|
725
|
+
* `E`/`G`/`R` either: those are about round-trip print fidelity for an arbitrary number, not a shape
|
|
726
|
+
* any real route has a reason to ask for.
|
|
727
|
+
*/
|
|
728
|
+
formatNumber(value, format) {
|
|
729
|
+
if (/^0+$/.test(format)) {
|
|
730
|
+
return Number.isInteger(value) ? this.padInteger(value, format.length) : null;
|
|
731
|
+
}
|
|
732
|
+
const standard = /^([DdXxFfNn])(\d{0,2})$/.exec(format);
|
|
733
|
+
if (!standard) {
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
const [, specifier, precisionText] = standard;
|
|
737
|
+
const precision = precisionText === '' ? null : Number(precisionText);
|
|
738
|
+
switch (specifier.toUpperCase()) {
|
|
739
|
+
case 'D':
|
|
740
|
+
return Number.isInteger(value) ? this.padInteger(value, precision ?? 0) : null;
|
|
741
|
+
case 'X':
|
|
742
|
+
return Number.isInteger(value) && value >= 0 ? this.formatHex(value, precision ?? 0, specifier === 'X') : null;
|
|
743
|
+
case 'F':
|
|
744
|
+
return value.toFixed(precision ?? 2);
|
|
745
|
+
case 'N':
|
|
746
|
+
return this.groupThousands(value.toFixed(precision ?? 2));
|
|
747
|
+
default:
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
padInteger(value, width) {
|
|
752
|
+
const negative = value < 0;
|
|
753
|
+
const padded = Math.abs(value).toString().padStart(width, '0');
|
|
754
|
+
return negative ? `-${padded}` : padded;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Negative values are rejected before this is ever called - .NET's `X` renders a negative signed
|
|
758
|
+
* integer as its two's-complement bit pattern, which depends on the source type's own width (`int` vs
|
|
759
|
+
* `long` vs `byte` produce different digits for the same value), information a form control's plain
|
|
760
|
+
* number does not carry. Guessing a width would risk a preview that looks plausible and is wrong;
|
|
761
|
+
* not attempting it is the same call already made for a Guid's `X` and for `C`/`P`/`E`/`G`/`R`.
|
|
762
|
+
*/
|
|
763
|
+
formatHex(value, width, uppercase) {
|
|
764
|
+
const hex = value.toString(16).padStart(width, '0');
|
|
765
|
+
return uppercase ? hex.toUpperCase() : hex;
|
|
766
|
+
}
|
|
767
|
+
groupThousands(fixedValue) {
|
|
768
|
+
const negative = fixedValue.startsWith('-');
|
|
769
|
+
const [integerPart, fractionPart] = (negative ? fixedValue.slice(1) : fixedValue).split('.');
|
|
770
|
+
const grouped = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
771
|
+
return `${negative ? '-' : ''}${grouped}${fractionPart ? '.' + fractionPart : ''}`;
|
|
772
|
+
}
|
|
773
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.2", ngImport: i0, type: ContentEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
774
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.2.2", type: ContentEditorComponent, isStandalone: true, selector: "site-content-editor", ngImport: i0, template: "@if (!isLoading() && form) {\n <form [formGroup]=\"form\" (submit)=\"save()\">\n <div class=\"d-flex justify-content-between align-items-center mb-3\">\n <div>\n <h5 class=\"mb-0\">\n {{ (isEdit ? 'Site::EditContent' : 'Site::NewContent') | abpLocalization }}\n </h5>\n @if (page(); as currentPage) {\n <small class=\"text-muted\">{{ currentPage.displayName }}</small>\n }\n </div>\n <div>\n <a class=\"btn btn-secondary me-2\" [routerLink]=\"['/site/contents']\">\n {{ 'AbpUi::Cancel' | abpLocalization }}\n </a>\n <!-- A plain submit button, not `abp-button`: that component declares its own `click` output,\n so `(click)` binds to an EventEmitter instead of the DOM event, and it takes `buttonType`\n rather than `type`. Both make it easy to end up with a button that quietly never submits.\n Inside a form on a page, the native element says what it does. -->\n <button type=\"submit\" class=\"btn btn-primary\" [disabled]=\"!form.valid || isBusy()\">\n @if (isBusy()) {\n <span class=\"spinner-border spinner-border-sm me-1\" role=\"status\"></span>\n } @else {\n <i class=\"fa fa-check me-1\"></i>\n }\n {{ 'AbpUi::Save' | abpLocalization }}\n </button>\n </div>\n </div>\n\n <div class=\"row g-3\">\n <!-- Left: the content itself - whatever fields its content type defines. -->\n <div class=\"col-lg-8\">\n <div class=\"card h-100\">\n <div class=\"card-body\">\n @if (hasMultipleContentTypes) {\n <div class=\"mb-3 pb-3 border-bottom\">\n <label class=\"form-label\">\n {{ 'Site::DisplayName:Content.ContentType' | abpLocalization }}\n </label>\n <select\n class=\"form-select\"\n formControlName=\"contentTypeId\"\n (change)=\"onContentTypeChange()\"\n >\n @for (contentType of contentTypes(); track contentType.id) {\n <option [value]=\"contentType.id\">{{ contentType.displayName }}</option>\n }\n </select>\n <small class=\"form-text text-muted\">\n {{ 'Site::ContentTypeSwitchHint' | abpLocalization }}\n </small>\n </div>\n }\n\n @if (fields().length > 0) {\n @for (fieldValue of fields(); track fieldValue.field.name) {\n <ff-flex-field-control\n [fields]=\"fieldValue\"\n [entity]=\"form\"\n parentFieldName=\"flexFields\"\n [selected]=\"fieldValue.value\"\n ></ff-flex-field-control>\n @if (fieldErrorMessage(fieldValue); as message) {\n <div class=\"invalid-feedback d-block mb-3\">{{ message }}</div>\n }\n }\n } @else {\n <p class=\"text-muted mb-0\">{{ 'Site::NoFieldsArranged' | abpLocalization }}</p>\n }\n </div>\n </div>\n </div>\n\n <!-- Right: the system fields that place and schedule the content. -->\n <div class=\"col-lg-4\">\n <div class=\"card\">\n <div class=\"card-body\">\n @if (!hasMultipleContentTypes && contentTypes().length === 1) {\n <div class=\"mb-3\">\n <label class=\"form-label\">\n {{ 'Site::DisplayName:Content.ContentType' | abpLocalization }}\n </label>\n <input type=\"text\" class=\"form-control\" [value]=\"contentTypes()[0].displayName\" disabled />\n </div>\n }\n\n <div class=\"mb-3\">\n <label class=\"form-label\">\n {{ 'Site::DisplayName:Content.CultureName' | abpLocalization }}\n </label>\n <select class=\"form-select\" formControlName=\"cultureName\" (change)=\"onCultureNameChange()\">\n @for (language of languages(); track language) {\n <option [value]=\"language\">{{ language }}</option>\n }\n </select>\n </div>\n\n <!-- The page's route (\u603B\u4F53\u8BBE\u8BA1 \u00A73.3) decides this, not the editor: no {slug}/{slug?} at all\n means nothing is shown here - there is nothing to choose. -->\n @if (slugState !== 'forbidden') {\n <div class=\"mb-3\">\n <label class=\"form-label\">{{ 'Site::DisplayName:Content.Slug' | abpLocalization }}</label>\n\n @if (slugState === 'optional' && !emptySlotTaken()) {\n <div class=\"form-check mb-2\">\n <input\n type=\"checkbox\"\n class=\"form-check-input\"\n id=\"site-use-page-own-address\"\n formControlName=\"usePageOwnAddress\"\n (change)=\"onUsePageOwnAddressChange()\"\n />\n <label class=\"form-check-label\" for=\"site-use-page-own-address\">\n {{ 'Site::UsePageOwnAddress' | abpLocalization }}\n </label>\n </div>\n }\n\n @if (!usePageOwnAddress) {\n <input type=\"text\" class=\"form-control\" formControlName=\"slug\" />\n <small class=\"form-text text-muted d-block mt-1\">\n {{ 'Site::Description:Content.Slug' | abpLocalization }}\n </small>\n }\n\n @if (slugState === 'optional' && emptySlotTaken()) {\n <small class=\"form-text text-muted d-block mt-1\">\n {{ 'Site::SlugSlotTaken' | abpLocalization }}\n </small>\n }\n\n <small class=\"form-text text-muted d-block mt-1\">\n {{ 'Site::SlugPreview' | abpLocalization: slugPreview }}\n </small>\n </div>\n }\n\n <div class=\"mb-3\">\n <label class=\"form-label\">{{ 'Site::DisplayName:Content.Status' | abpLocalization }}</label>\n <select class=\"form-select\" formControlName=\"status\" (change)=\"onStatusChange()\">\n @for (status of statuses; track status) {\n <option [ngValue]=\"status\">{{ statusKeyOf(status) | abpLocalization }}</option>\n }\n </select>\n </div>\n\n <div class=\"mb-0\">\n <label class=\"form-label\">\n {{ 'Site::DisplayName:Content.PublishTime' | abpLocalization }}\n </label>\n <input type=\"datetime-local\" class=\"form-control\" formControlName=\"publishTime\" />\n @if (isDraft) {\n <small class=\"form-text text-muted\">\n {{ 'Site::DraftPublishTimeDisabledHint' | abpLocalization }}\n </small>\n }\n </div>\n </div>\n </div>\n </div>\n </div>\n </form>\n}\n", dependencies: [{ kind: "ngmodule", type: CoreModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox]:not([ngNoCva])[formControlName],input[type=checkbox]:not([ngNoCva])[formControl],input[type=checkbox]:not([ngNoCva])[ngModel]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple]):not([ngNoCva])[formControlName],select:not([multiple]):not([ngNoCva])[formControl],select:not([multiple]):not([ngNoCva])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "directive", type: i2.RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "browserUrl", "routerLink"] }, { kind: "directive", type: i3.ValidationGroupDirective, selector: "[formGroup],[formGroupName]", exportAs: ["validationGroup"] }, { kind: "directive", type: i3.ValidationDirective, selector: "[formControl],[formControlName]", exportAs: ["validationDirective"] }, { kind: "ngmodule", type: ThemeSharedModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "component", type: FlexFieldControlComponent, selector: "ff-flex-field-control", inputs: ["fields", "entity", "parentFieldName", "selected"] }, { kind: "pipe", type: i4.LocalizationPipe, name: "abpLocalization" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
775
|
+
}
|
|
776
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.2", ngImport: i0, type: ContentEditorComponent, decorators: [{
|
|
777
|
+
type: Component,
|
|
778
|
+
args: [{ selector: 'site-content-editor', changeDetection: ChangeDetectionStrategy.OnPush, imports: [CoreModule, ThemeSharedModule, ReactiveFormsModule, RouterLink, FlexFieldControlComponent], template: "@if (!isLoading() && form) {\n <form [formGroup]=\"form\" (submit)=\"save()\">\n <div class=\"d-flex justify-content-between align-items-center mb-3\">\n <div>\n <h5 class=\"mb-0\">\n {{ (isEdit ? 'Site::EditContent' : 'Site::NewContent') | abpLocalization }}\n </h5>\n @if (page(); as currentPage) {\n <small class=\"text-muted\">{{ currentPage.displayName }}</small>\n }\n </div>\n <div>\n <a class=\"btn btn-secondary me-2\" [routerLink]=\"['/site/contents']\">\n {{ 'AbpUi::Cancel' | abpLocalization }}\n </a>\n <!-- A plain submit button, not `abp-button`: that component declares its own `click` output,\n so `(click)` binds to an EventEmitter instead of the DOM event, and it takes `buttonType`\n rather than `type`. Both make it easy to end up with a button that quietly never submits.\n Inside a form on a page, the native element says what it does. -->\n <button type=\"submit\" class=\"btn btn-primary\" [disabled]=\"!form.valid || isBusy()\">\n @if (isBusy()) {\n <span class=\"spinner-border spinner-border-sm me-1\" role=\"status\"></span>\n } @else {\n <i class=\"fa fa-check me-1\"></i>\n }\n {{ 'AbpUi::Save' | abpLocalization }}\n </button>\n </div>\n </div>\n\n <div class=\"row g-3\">\n <!-- Left: the content itself - whatever fields its content type defines. -->\n <div class=\"col-lg-8\">\n <div class=\"card h-100\">\n <div class=\"card-body\">\n @if (hasMultipleContentTypes) {\n <div class=\"mb-3 pb-3 border-bottom\">\n <label class=\"form-label\">\n {{ 'Site::DisplayName:Content.ContentType' | abpLocalization }}\n </label>\n <select\n class=\"form-select\"\n formControlName=\"contentTypeId\"\n (change)=\"onContentTypeChange()\"\n >\n @for (contentType of contentTypes(); track contentType.id) {\n <option [value]=\"contentType.id\">{{ contentType.displayName }}</option>\n }\n </select>\n <small class=\"form-text text-muted\">\n {{ 'Site::ContentTypeSwitchHint' | abpLocalization }}\n </small>\n </div>\n }\n\n @if (fields().length > 0) {\n @for (fieldValue of fields(); track fieldValue.field.name) {\n <ff-flex-field-control\n [fields]=\"fieldValue\"\n [entity]=\"form\"\n parentFieldName=\"flexFields\"\n [selected]=\"fieldValue.value\"\n ></ff-flex-field-control>\n @if (fieldErrorMessage(fieldValue); as message) {\n <div class=\"invalid-feedback d-block mb-3\">{{ message }}</div>\n }\n }\n } @else {\n <p class=\"text-muted mb-0\">{{ 'Site::NoFieldsArranged' | abpLocalization }}</p>\n }\n </div>\n </div>\n </div>\n\n <!-- Right: the system fields that place and schedule the content. -->\n <div class=\"col-lg-4\">\n <div class=\"card\">\n <div class=\"card-body\">\n @if (!hasMultipleContentTypes && contentTypes().length === 1) {\n <div class=\"mb-3\">\n <label class=\"form-label\">\n {{ 'Site::DisplayName:Content.ContentType' | abpLocalization }}\n </label>\n <input type=\"text\" class=\"form-control\" [value]=\"contentTypes()[0].displayName\" disabled />\n </div>\n }\n\n <div class=\"mb-3\">\n <label class=\"form-label\">\n {{ 'Site::DisplayName:Content.CultureName' | abpLocalization }}\n </label>\n <select class=\"form-select\" formControlName=\"cultureName\" (change)=\"onCultureNameChange()\">\n @for (language of languages(); track language) {\n <option [value]=\"language\">{{ language }}</option>\n }\n </select>\n </div>\n\n <!-- The page's route (\u603B\u4F53\u8BBE\u8BA1 \u00A73.3) decides this, not the editor: no {slug}/{slug?} at all\n means nothing is shown here - there is nothing to choose. -->\n @if (slugState !== 'forbidden') {\n <div class=\"mb-3\">\n <label class=\"form-label\">{{ 'Site::DisplayName:Content.Slug' | abpLocalization }}</label>\n\n @if (slugState === 'optional' && !emptySlotTaken()) {\n <div class=\"form-check mb-2\">\n <input\n type=\"checkbox\"\n class=\"form-check-input\"\n id=\"site-use-page-own-address\"\n formControlName=\"usePageOwnAddress\"\n (change)=\"onUsePageOwnAddressChange()\"\n />\n <label class=\"form-check-label\" for=\"site-use-page-own-address\">\n {{ 'Site::UsePageOwnAddress' | abpLocalization }}\n </label>\n </div>\n }\n\n @if (!usePageOwnAddress) {\n <input type=\"text\" class=\"form-control\" formControlName=\"slug\" />\n <small class=\"form-text text-muted d-block mt-1\">\n {{ 'Site::Description:Content.Slug' | abpLocalization }}\n </small>\n }\n\n @if (slugState === 'optional' && emptySlotTaken()) {\n <small class=\"form-text text-muted d-block mt-1\">\n {{ 'Site::SlugSlotTaken' | abpLocalization }}\n </small>\n }\n\n <small class=\"form-text text-muted d-block mt-1\">\n {{ 'Site::SlugPreview' | abpLocalization: slugPreview }}\n </small>\n </div>\n }\n\n <div class=\"mb-3\">\n <label class=\"form-label\">{{ 'Site::DisplayName:Content.Status' | abpLocalization }}</label>\n <select class=\"form-select\" formControlName=\"status\" (change)=\"onStatusChange()\">\n @for (status of statuses; track status) {\n <option [ngValue]=\"status\">{{ statusKeyOf(status) | abpLocalization }}</option>\n }\n </select>\n </div>\n\n <div class=\"mb-0\">\n <label class=\"form-label\">\n {{ 'Site::DisplayName:Content.PublishTime' | abpLocalization }}\n </label>\n <input type=\"datetime-local\" class=\"form-control\" formControlName=\"publishTime\" />\n @if (isDraft) {\n <small class=\"form-text text-muted\">\n {{ 'Site::DraftPublishTimeDisabledHint' | abpLocalization }}\n </small>\n }\n </div>\n </div>\n </div>\n </div>\n </div>\n </form>\n}\n" }]
|
|
779
|
+
}], ctorParameters: () => [] });
|
|
780
|
+
|
|
781
|
+
export { ContentEditorComponent };
|
|
782
|
+
//# sourceMappingURL=dignite-ng.site-content-editor.component-iFWDeYJ9.mjs.map
|