}
+ prepend={
}
closeBtnProps={{
onClick: () => alert('Close triggered!'),
}}
diff --git a/services/web/frontend/stories/ui/tag-bs5.stories.tsx b/services/web/frontend/stories/ui/tag-bs5.stories.tsx
index 1bcd160896..e4f09c033d 100644
--- a/services/web/frontend/stories/ui/tag-bs5.stories.tsx
+++ b/services/web/frontend/stories/ui/tag-bs5.stories.tsx
@@ -1,4 +1,4 @@
-import Icon from '@/shared/components/icon'
+import OLTagIcon from '@/features/ui/components/ol/icons/ol-tag-icon'
import Tag from '@/features/ui/components/bootstrap-5/tag'
import type { Meta, StoryObj } from '@storybook/react'
@@ -41,7 +41,7 @@ export const TagDefault: Story = {
export const TagPrepend: Story = {
render: args => {
- return
} {...args} />
+ return
} {...args} />
},
}
@@ -49,7 +49,7 @@ export const TagWithCloseButton: Story = {
render: args => {
return (
}
+ prepend={
}
closeBtnProps={{
onClick: () => alert('Close triggered!'),
}}
@@ -63,7 +63,7 @@ export const TagWithContentButtonAndCloseButton: Story = {
render: args => {
return (
}
+ prepend={
}
contentProps={{
onClick: () => alert('Content button clicked!'),
}}
diff --git a/services/web/frontend/stylesheets/app/editor/history-react.less b/services/web/frontend/stylesheets/app/editor/history-react.less
index f000c3b6b6..0917155254 100644
--- a/services/web/frontend/stylesheets/app/editor/history-react.less
+++ b/services/web/frontend/stylesheets/app/editor/history-react.less
@@ -44,9 +44,14 @@ history-root {
.doc-container {
flex: 1;
overflow-y: auto;
+ display: flex;
}
}
+ .doc-container .loading {
+ margin: 10rem auto auto;
+ }
+
.change-list {
display: flex;
flex-direction: column;
@@ -102,6 +107,7 @@ history-root {
}
.history-version-details {
+ display: flow-root;
padding-top: 8px;
padding-bottom: 8px;
position: relative;
@@ -240,20 +246,15 @@ history-root {
}
.loading {
- padding-top: 10rem;
font-family: @font-family-serif;
- text-align: center;
}
- & > .loading {
- flex: 1;
- }
-
- .history-all-versions-scroller .loading {
+ .history-all-versions-loading {
position: sticky;
bottom: 0;
padding: @line-height-computed / 2 0;
background-color: @gray-lightest;
+ text-align: center;
}
.history-version-saved-by {
@@ -271,8 +272,11 @@ history-root {
.history-compare-btn,
.history-version-dropdown-menu-btn {
+ .reset-button;
+
@size: 30px;
padding: 0;
+ border-radius: @btn-border-radius-large;
width: @size;
height: @size;
line-height: 1;
diff --git a/services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss b/services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss
index 9747dd7027..af899105eb 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss
@@ -45,6 +45,20 @@
}
}
+@mixin action-button {
+ font-size: 0;
+ line-height: 1;
+ border-radius: 50%;
+ color: var(--content-primary);
+ background-color: transparent;
+
+ &:hover,
+ &:active,
+ &[aria-expanded='true'] {
+ background-color: rgb($neutral-90, 0.08);
+ }
+}
+
@mixin reset-button() {
padding: 0;
cursor: pointer;
@@ -104,3 +118,28 @@
transparent
);
}
+
+@mixin mask-image($gradient) {
+ // mask-image isn't supported without the -webkit prefix on all browsers we support yet
+ -webkit-mask-image: $gradient;
+ mask-image: $gradient;
+}
+
+@mixin premium-background {
+ background-image: var(--premium-gradient);
+}
+
+@mixin premium-text {
+ @include premium-background;
+
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+ color: var(--white); // Fallback
+ background-color: var(--blue-70); // Fallback
+}
+
+@mixin dark-bg {
+ --link-color: var(--link-color-dark);
+ --link-hover-color: var(--link-hover-color-dark);
+ --link-visited-color: var(--link-visited-color-dark);
+}
diff --git a/services/web/frontend/stylesheets/bootstrap-5/base/links.scss b/services/web/frontend/stylesheets/bootstrap-5/base/links.scss
index c24d974366..db53d4d8f7 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/base/links.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/base/links.scss
@@ -5,6 +5,9 @@
--link-color: var(--link-ui);
--link-hover-color: var(--link-ui-hover);
--link-visited-color: var(--link-ui-visited);
+ --link-color-dark: var(--link-ui-dark);
+ --link-hover-color-dark: var(--link-ui-hover-dark);
+ --link-visited-color-dark: var(--link-ui-visited-dark);
}
a {
diff --git a/services/web/frontend/stylesheets/bootstrap-5/base/typography.scss b/services/web/frontend/stylesheets/bootstrap-5/base/typography.scss
index fb434edb3f..5b2adf6587 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/base/typography.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/base/typography.scss
@@ -74,10 +74,6 @@ samp {
list-style-image: url('../../../../public/img/fa-check-green.svg');
}
-.text-center {
- text-align: center;
-}
-
.text-muted {
color: var(--content-disabled) !important;
}
diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/all.scss b/services/web/frontend/stylesheets/bootstrap-5/components/all.scss
index 9b4c5b4d9e..2e4824a2e1 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/components/all.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/components/all.scss
@@ -29,3 +29,4 @@
@import 'pagination';
@import 'loading-spinner';
@import 'error-boundary';
+@import 'close-button';
diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/badge.scss b/services/web/frontend/stylesheets/bootstrap-5/components/badge.scss
index 0c1178dd50..1b7534c125 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/components/badge.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/components/badge.scss
@@ -16,6 +16,10 @@ $max-width: 160px;
margin-right: var(--spacing-02);
display: flex;
align-items: center;
+
+ .material-symbols {
+ font-size: inherit;
+ }
}
.badge-close {
diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/button.scss b/services/web/frontend/stylesheets/bootstrap-5/components/button.scss
index 19cf06865a..286a46b8f8 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/components/button.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/components/button.scss
@@ -164,14 +164,14 @@
.loading-spinner-small {
border-width: 0.2em;
- height: 20px;
- width: 20px;
+ height: 1.25rem;
+ width: 1.25rem;
}
.loading-spinner-large {
border-width: 0.2em;
- height: 24px;
- width: 24px;
+ height: 1.5rem;
+ width: 1.5rem;
}
}
@@ -187,11 +187,11 @@
justify-content: center;
.icon-small {
- font-size: 20px;
+ font-size: 1.25rem;
}
.icon-large {
- font-size: 24px;
+ font-size: 1.5rem;
}
.spinner {
diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/close-button.scss b/services/web/frontend/stylesheets/bootstrap-5/components/close-button.scss
new file mode 100644
index 0000000000..bb3e78f3f7
--- /dev/null
+++ b/services/web/frontend/stylesheets/bootstrap-5/components/close-button.scss
@@ -0,0 +1,10 @@
+// This is our own implementation because the Bootstrap close button requires more customization than is worthwhile
+.close {
+ @include reset-button;
+
+ color: var(--content-primary);
+
+ &.dark {
+ color: var(--content-primary-dark);
+ }
+}
diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/dropdown-menu.scss b/services/web/frontend/stylesheets/bootstrap-5/components/dropdown-menu.scss
index d8b30248de..3dc5777795 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/components/dropdown-menu.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/components/dropdown-menu.scss
@@ -167,3 +167,15 @@
.dropdown-item-highlighted {
background-color: var(--bg-light-secondary);
}
+
+.dropdown-item-material-icon-small {
+ .material-symbols,
+ &.material-symbols {
+ font-size: var(--bs-body-font-size);
+
+ // Centre the symbol in a 20px-by-20px box
+ width: 20px;
+ line-height: 20px;
+ text-align: center;
+ }
+}
diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/loading-spinner.scss b/services/web/frontend/stylesheets/bootstrap-5/components/loading-spinner.scss
index a92aae35d3..3c92ce6c35 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/components/loading-spinner.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/components/loading-spinner.scss
@@ -1,3 +1,17 @@
+.loading {
+ .spinner-border-sm,
+ .spinner-border {
+ // Ensure the thickness of the spinner is independent of the font size of its container
+ font-size: var(--font-size-03);
+ }
+
+ // Adjust the small spinner to be 25% larger than Bootstrap's default in each dimension
+ .spinner-border-sm {
+ --bs-spinner-width: 1.25rem;
+ --bs-spinner-height: 1.25rem;
+ }
+}
+
.full-size-loading-spinner-container {
width: 100%;
height: 100%;
diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/popover.scss b/services/web/frontend/stylesheets/bootstrap-5/components/popover.scss
index d1bacede62..af951e9510 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/components/popover.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/components/popover.scss
@@ -1,5 +1,6 @@
.popover {
@include shadow-md;
+ @include dark-bg;
line-height: var(--line-height-02);
}
diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/system-messages.scss b/services/web/frontend/stylesheets/bootstrap-5/components/system-messages.scss
index f244107521..13455447d7 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/components/system-messages.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/components/system-messages.scss
@@ -17,9 +17,3 @@
}
}
}
-
-.system-message .close {
- @include reset-button;
-
- color: var(--content-primary-dark);
-}
diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss
index bfd18c084f..f3f7e0e844 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss
@@ -15,6 +15,7 @@
@import 'editor/figure-modal';
@import 'editor/review-panel';
@import 'editor/chat';
+@import 'editor/history';
@import 'subscription';
@import 'editor/pdf';
@import 'editor/compile-button';
diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss
new file mode 100644
index 0000000000..bfb986767e
--- /dev/null
+++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss
@@ -0,0 +1,383 @@
+history-root {
+ height: 100%;
+ display: block;
+}
+
+// Adding !important to override the styling of overlays and popovers
+.history-popover .popover-arrow {
+ top: 20px !important;
+ transform: unset !important;
+}
+
+.history-react {
+ --history-change-list-padding: var(--spacing-06);
+
+ display: flex;
+ height: 100%;
+ background-color: var(--bg-light-primary);
+
+ .history-header {
+ @include body-sm;
+
+ height: 40px;
+ background-color: var(--bg-dark-secondary);
+ color: var(--content-primary-dark);
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ box-sizing: border-box;
+ }
+
+ .doc-panel {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+
+ .toolbar-container {
+ border-bottom: 1px solid var(--border-divider-dark);
+ padding: 0 var(--spacing-04);
+ }
+
+ .doc-container {
+ flex: 1;
+ overflow-y: auto;
+ display: flex;
+ }
+ }
+
+ .doc-container .loading {
+ margin: 10rem auto auto;
+ }
+
+ .change-list {
+ @include body-sm;
+
+ display: flex;
+ flex-direction: column;
+ width: 320px;
+ border-left: 1px solid var(--border-divider-dark);
+ box-sizing: content-box;
+ }
+
+ .toggle-switch-label {
+ flex: 1;
+
+ span {
+ display: block;
+ }
+ }
+
+ .history-version-list-container {
+ flex: 1;
+ overflow-y: auto;
+ }
+
+ .history-all-versions-scroller {
+ overflow-y: auto;
+ height: 100%;
+ }
+
+ .history-all-versions-container {
+ position: relative;
+ }
+
+ .history-versions-bottom {
+ position: absolute;
+ height: 8em;
+ bottom: 0;
+ }
+
+ .history-toggle-switch-container,
+ .history-version-day,
+ .history-version-details {
+ padding: 0 var(--history-change-list-padding);
+ }
+
+ .history-version-day {
+ background-color: white;
+ position: sticky;
+ z-index: 1;
+ top: 0;
+ display: block;
+ padding-top: var(--spacing-05);
+ padding-bottom: var(--spacing-02);
+ line-height: var(--line-height-02);
+ }
+
+ .history-version-details {
+ display: flow-root;
+ padding-top: var(--spacing-04);
+ padding-bottom: var(--spacing-04);
+ position: relative;
+
+ &.history-version-selectable {
+ cursor: pointer;
+
+ &:hover {
+ background-color: var(--bg-light-secondary);
+ }
+ }
+
+ &.history-version-selected {
+ background-color: var(--bg-accent-03);
+ border-left: var(--spacing-02) solid var(--green-50);
+ padding-left: calc(
+ var(--history-change-list-padding) - var(--spacing-02)
+ );
+ }
+
+ &.history-version-selected.history-version-selectable:hover {
+ background-color: rgb($green-70, 16%);
+ border-left: var(--spacing-02) solid var(--green-50);
+ }
+
+ &.history-version-within-selected {
+ background-color: var(--bg-light-secondary);
+ border-left: var(--spacing-02) solid var(--green-50);
+ }
+
+ &.history-version-within-selected:hover {
+ background-color: rgb($neutral-90, 8%);
+ }
+ }
+
+ .version-element-within-selected {
+ background-color: var(--bg-light-secondary);
+ border-left: var(--spacing-02) solid var(--green-50);
+ }
+
+ .version-element-selected {
+ background-color: var(--bg-accent-03);
+ border-left: var(--spacing-02) solid var(--green-50);
+ }
+
+ .history-version-metadata-time {
+ display: block;
+ margin-bottom: var(--spacing-02);
+ color: var(--content-primary);
+
+ &:last-child {
+ margin-bottom: initial;
+ }
+ }
+
+ .history-version-metadata-users,
+ .history-version-changes {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+
+ .history-version-restore-file {
+ margin-bottom: var(--spacing-04);
+ }
+
+ .history-version-metadata-users {
+ display: inline;
+ vertical-align: bottom;
+
+ > li {
+ display: inline-flex;
+ align-items: center;
+ margin-right: var(--spacing-04);
+ }
+ }
+
+ .history-version-changes {
+ > li {
+ margin-bottom: var(--spacing-02);
+ }
+ }
+
+ .history-version-user-badge-color {
+ --badge-size: 8px;
+
+ display: inline-block;
+ width: var(--badge-size);
+ height: var(--badge-size);
+ margin-right: var(--spacing-02);
+ border-radius: 2px;
+ }
+
+ .history-version-user-badge-text {
+ overflow-wrap: anywhere;
+ flex: 1;
+ }
+
+ .history-version-day,
+ .history-version-change-action,
+ .history-version-metadata-users,
+ .history-version-origin,
+ .history-version-saved-by {
+ color: var(--content-secondary);
+ }
+
+ .history-version-change-action {
+ overflow-wrap: anywhere;
+ }
+
+ .history-version-change-doc {
+ color: var(--content-primary);
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+ }
+
+ .history-version-divider-container {
+ padding: var(--spacing-03) var(--spacing-04);
+ }
+
+ .history-version-divider {
+ margin: 0;
+ border-color: var(--border-divider);
+ }
+
+ .history-version-badge {
+ margin-bottom: var(--spacing-02);
+ margin-right: var(--spacing-05);
+ height: unset;
+ white-space: normal;
+ overflow-wrap: anywhere;
+
+ .material-symbols {
+ font-size: inherit;
+ }
+ }
+
+ .history-version-label {
+ margin-bottom: var(--spacing-02);
+
+ &:last-child {
+ margin-bottom: initial;
+ }
+ }
+
+ .loading {
+ font-family: $font-family-serif;
+ }
+
+ .history-all-versions-loading {
+ position: sticky;
+ bottom: 0;
+ padding: var(--spacing-05) 0;
+ background-color: var(--bg-light-secondary);
+ text-align: center;
+ }
+
+ .history-version-saved-by {
+ .history-version-saved-by-label {
+ margin-right: var(--spacing-04);
+ }
+ }
+
+ .dropdown.open {
+ .history-version-dropdown-menu-btn {
+ background-color: rgb(var(--bg-dark-primary) 0.08);
+ box-shadow: initial;
+ }
+ }
+
+ .history-compare-btn,
+ .history-version-dropdown-menu-btn {
+ @include reset-button;
+ @include action-button;
+
+ padding: 0;
+ width: 30px;
+ height: 30px;
+ }
+
+ .history-loading-panel {
+ padding-top: 10rem;
+ font-family: $font-family-serif;
+ text-align: center;
+ }
+
+ .history-paywall-prompt {
+ padding: var(--history-change-list-padding);
+
+ .history-feature-list {
+ list-style: none;
+ padding-left: var(--spacing-04);
+
+ li {
+ margin-bottom: var(--spacing-06);
+ }
+ }
+
+ button {
+ width: 100%;
+ }
+ }
+
+ .history-version-faded .history-version-details {
+ max-height: 6em;
+
+ @include mask-image(linear-gradient(black 35%, transparent));
+
+ overflow: hidden;
+ }
+
+ .history-paywall-heading {
+ @include heading-sm;
+ @include premium-text;
+
+ font-family: inherit;
+ font-weight: 700;
+ margin-top: var(--spacing-08);
+ }
+
+ .history-content {
+ padding: var(--spacing-05);
+ }
+}
+
+.history-version-label-tooltip {
+ padding: 6px;
+ text-align: initial;
+
+ .history-version-label-tooltip-row {
+ margin-bottom: var(--spacing-03);
+
+ .history-version-label-tooltip-row-comment {
+ overflow-wrap: anywhere;
+
+ & .material-symbols {
+ font-size: inherit;
+ }
+ }
+
+ &:last-child {
+ margin-bottom: initial;
+ }
+ }
+}
+
+.history-version-dropdown-menu {
+ [role='menuitem'] {
+ padding: var(--spacing-05);
+ color: var(--content-primary);
+
+ &:hover,
+ &:focus {
+ color: var(--content-primary);
+ background-color: var(--bg-light-secondary);
+ }
+ }
+}
+
+.history-dropdown-icon {
+ color: var(--content-primary);
+}
+
+.history-dropdown-icon-inverted {
+ color: var(--neutral-10);
+ vertical-align: top;
+}
+
+.history-restore-promo-icon {
+ vertical-align: middle;
+}
+
+.history-error {
+ padding: 16px;
+}
diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss
index f0ed2e720c..2eb210dce8 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss
@@ -328,20 +328,25 @@
***************************************/
.toggle-switch {
+ --toggle-switch-height: 26px;
+ --toggle-switch-padding: var(--spacing-01);
+
display: inline-flex;
align-items: center;
- height: 26px;
+ height: var(--toggle-switch-height);
margin-right: var(--spacing-03);
border-radius: var(--border-radius-full);
background-color: var(--neutral-20);
- padding: var(--spacing-01);
+ padding: var(--toggle-switch-padding);
}
.toggle-switch-label {
display: inline-block;
float: left;
font-weight: normal;
- height: 100%;
+
+ // It seems we need to set the height explicitly rather than using 100% to get the button to display correctly in Blink
+ height: calc(var(--toggle-switch-height) - 2 * var(--toggle-switch-padding));
text-align: center;
margin: 0;
cursor: pointer;
diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss
index 9514c64047..ca949ef486 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss
@@ -657,17 +657,9 @@
}
.dropdown-table-button-toggle {
- padding: var(--spacing-04);
- font-size: 0;
- line-height: 1;
- border-radius: 50%;
- color: var(--content-primary);
- background-color: transparent;
+ @include action-button;
- &:hover,
- &:active {
- background-color: rgba($neutral-90, 0.08);
- }
+ padding: var(--spacing-04);
}
}
}
diff --git a/services/web/frontend/stylesheets/components/loading-spinner.less b/services/web/frontend/stylesheets/components/loading-spinner.less
index a92aae35d3..7640caaa7c 100644
--- a/services/web/frontend/stylesheets/components/loading-spinner.less
+++ b/services/web/frontend/stylesheets/components/loading-spinner.less
@@ -5,3 +5,8 @@
align-items: center;
justify-content: center;
}
+
+.loading {
+ display: inline-flex;
+ align-items: center;
+}
diff --git a/services/web/test/frontend/features/history/components/change-list-bs5.spec.tsx b/services/web/test/frontend/features/history/components/change-list-bs5.spec.tsx
new file mode 100644
index 0000000000..c950bb41ad
--- /dev/null
+++ b/services/web/test/frontend/features/history/components/change-list-bs5.spec.tsx
@@ -0,0 +1,684 @@
+import '../../../helpers/bootstrap-5'
+import { useState } from 'react'
+import ToggleSwitch from '../../../../../frontend/js/features/history/components/change-list/toggle-switch'
+import ChangeList from '../../../../../frontend/js/features/history/components/change-list/change-list'
+import {
+ EditorProviders,
+ USER_EMAIL,
+ USER_ID,
+} from '../../../helpers/editor-providers'
+import { HistoryProvider } from '../../../../../frontend/js/features/history/context/history-context'
+import { updates } from '../fixtures/updates'
+import { labels } from '../fixtures/labels'
+import {
+ formatTime,
+ relativeDate,
+} from '../../../../../frontend/js/features/utils/format-date'
+
+const mountWithEditorProviders = (
+ component: React.ReactNode,
+ scope: Record
= {},
+ props: Record = {}
+) => {
+ cy.mount(
+
+
+
+
+
+ )
+}
+
+describe('change list (Bootstrap 5)', function () {
+ const scope = {
+ ui: { view: 'history', pdfLayout: 'sideBySide', chatOpen: true },
+ }
+
+ const waitForData = () => {
+ cy.wait('@updates')
+ cy.wait('@labels')
+ cy.wait('@diff')
+ }
+
+ beforeEach(function () {
+ cy.intercept('GET', '/project/*/updates*', {
+ body: updates,
+ }).as('updates')
+ cy.intercept('GET', '/project/*/labels', {
+ body: labels,
+ }).as('labels')
+ cy.intercept('GET', '/project/*/filetree/diff*', {
+ body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
+ }).as('diff')
+ window.metaAttributesCache.set('ol-inactiveTutorials', [
+ 'react-history-buttons-tutorial',
+ ])
+ })
+
+ describe('toggle switch', function () {
+ it('renders switch buttons', function () {
+ mountWithEditorProviders(
+ {}} />
+ )
+
+ cy.findByLabelText(/all history/i)
+ cy.findByLabelText(/labels/i)
+ })
+
+ it('toggles "all history" and "labels" buttons', function () {
+ function ToggleSwitchWrapped({ labelsOnly }: { labelsOnly: boolean }) {
+ const [labelsOnlyLocal, setLabelsOnlyLocal] = useState(labelsOnly)
+ return (
+
+ )
+ }
+
+ mountWithEditorProviders()
+
+ cy.findByLabelText(/all history/i).as('all-history')
+ cy.findByLabelText(/labels/i).as('labels')
+ cy.get('@all-history').should('be.checked')
+ cy.get('@labels').should('not.be.checked')
+ cy.get('@labels').click({ force: true })
+ cy.get('@all-history').should('not.be.checked')
+ cy.get('@labels').should('be.checked')
+ })
+ })
+
+ describe('tags', function () {
+ it('renders tags', function () {
+ mountWithEditorProviders(, scope, {
+ user: {
+ id: USER_ID,
+ email: USER_EMAIL,
+ isAdmin: true,
+ },
+ })
+ waitForData()
+
+ cy.findByLabelText(/all history/i).click({ force: true })
+ cy.findAllByTestId('history-version-details').as('details')
+ cy.get('@details').should('have.length', 5)
+ // start with 2nd details entry, as first has no tags
+ cy.get('@details')
+ .eq(1)
+ .within(() => {
+ cy.findAllByTestId('history-version-badge').as('tags')
+ })
+ cy.get('@tags').should('have.length', 2)
+ cy.get('@tags').eq(0).should('contain.text', 'tag-2')
+ cy.get('@tags').eq(1).should('contain.text', 'tag-1')
+ // should have delete buttons
+ cy.get('@tags').each(tag =>
+ cy.wrap(tag).within(() => {
+ cy.findByRole('button', { name: /delete/i })
+ })
+ )
+ // 3rd details entry
+ cy.get('@details')
+ .eq(2)
+ .within(() => {
+ cy.findAllByTestId('history-version-badge').should('have.length', 0)
+ })
+ // 4th details entry
+ cy.get('@details')
+ .eq(3)
+ .within(() => {
+ cy.findAllByTestId('history-version-badge').as('tags')
+ })
+ cy.get('@tags').should('have.length', 2)
+ cy.get('@tags').eq(0).should('contain.text', 'tag-4')
+ cy.get('@tags').eq(1).should('contain.text', 'tag-3')
+ // should not have delete buttons
+ cy.get('@tags').each(tag =>
+ cy.wrap(tag).within(() => {
+ cy.findByRole('button', { name: /delete/i }).should('not.exist')
+ })
+ )
+ cy.findByLabelText(/labels/i).click({ force: true })
+ cy.findAllByTestId('history-version-details').as('details')
+ // first details on labels is always "current version", start testing on second
+ cy.get('@details').should('have.length', 3)
+ cy.get('@details')
+ .eq(1)
+ .within(() => {
+ cy.findAllByTestId('history-version-badge').as('tags')
+ })
+ cy.get('@tags').should('have.length', 2)
+ cy.get('@tags').eq(0).should('contain.text', 'tag-2')
+ cy.get('@tags').eq(1).should('contain.text', 'tag-1')
+ cy.get('@details')
+ .eq(2)
+ .within(() => {
+ cy.findAllByTestId('history-version-badge').as('tags')
+ })
+ cy.get('@tags').should('have.length', 3)
+ cy.get('@tags').eq(0).should('contain.text', 'tag-5')
+ cy.get('@tags').eq(1).should('contain.text', 'tag-4')
+ cy.get('@tags').eq(2).should('contain.text', 'tag-3')
+ })
+
+ it('deletes tag', function () {
+ mountWithEditorProviders(, scope, {
+ user: {
+ id: USER_ID,
+ email: USER_EMAIL,
+ isAdmin: true,
+ },
+ })
+ waitForData()
+
+ cy.findByLabelText(/all history/i).click({ force: true })
+
+ const labelToDelete = 'tag-2'
+ cy.findAllByTestId('history-version-details').eq(1).as('details')
+ cy.get('@details').within(() => {
+ cy.findAllByTestId('history-version-badge').eq(0).as('tag')
+ })
+ cy.get('@tag').should('contain.text', labelToDelete)
+ cy.get('@tag').within(() => {
+ cy.findByRole('button', { name: /delete/i }).as('delete-btn')
+ })
+ cy.get('@delete-btn').click()
+ cy.findByRole('dialog').as('modal')
+ cy.get('@modal').within(() => {
+ cy.findByRole('heading', { name: /delete label/i })
+ })
+ cy.get('@modal').contains(
+ new RegExp(
+ `are you sure you want to delete the following label "${labelToDelete}"?`,
+ 'i'
+ )
+ )
+ cy.get('@modal').within(() => {
+ cy.findByRole('button', { name: /cancel/i }).click()
+ })
+ cy.findByRole('dialog').should('not.exist')
+ cy.get('@delete-btn').click()
+ cy.findByRole('dialog').as('modal')
+ cy.intercept('DELETE', '/project/*/labels/*', {
+ statusCode: 500,
+ }).as('delete')
+ cy.get('@modal').within(() => {
+ cy.findByRole('button', { name: /delete/i }).click()
+ })
+ cy.wait('@delete')
+ cy.get('@modal').within(() => {
+ cy.findByRole('alert').within(() => {
+ cy.contains(/sorry, something went wrong/i)
+ })
+ })
+ cy.findByText(labelToDelete).should('have.length', 1)
+
+ cy.intercept('DELETE', '/project/*/labels/*', {
+ statusCode: 204,
+ }).as('delete')
+ cy.get('@modal').within(() => {
+ cy.findByRole('button', { name: /delete/i }).click()
+ })
+ cy.wait('@delete')
+ cy.findByText(labelToDelete).should('not.exist')
+ })
+
+ it('verifies that selecting the same list item will not trigger a new diff', function () {
+ mountWithEditorProviders(, scope, {
+ user: {
+ id: USER_ID,
+ email: USER_EMAIL,
+ isAdmin: true,
+ },
+ })
+ waitForData()
+
+ const stub = cy.stub().as('diffStub')
+ cy.intercept('GET', '/project/*/filetree/diff*', stub).as('diff')
+
+ cy.findAllByTestId('history-version-details').eq(2).as('details')
+ cy.get('@details').click() // 1st click
+ cy.wait('@diff')
+ cy.get('@details').click() // 2nd click
+ cy.get('@diffStub').should('have.been.calledOnce')
+ })
+ })
+
+ describe('all history', function () {
+ beforeEach(function () {
+ mountWithEditorProviders(, scope, {
+ user: {
+ id: USER_ID,
+ email: USER_EMAIL,
+ isAdmin: true,
+ },
+ })
+ waitForData()
+ })
+
+ it('shows grouped versions date', function () {
+ cy.findByText(relativeDate(updates.updates[0].meta.end_ts))
+ cy.findByText(relativeDate(updates.updates[1].meta.end_ts))
+ })
+
+ it('shows the date of the version', function () {
+ cy.findAllByTestId('history-version-details')
+ .eq(1)
+ .within(() => {
+ cy.findByTestId('history-version-metadata-time').should(
+ 'have.text',
+ formatTime(updates.updates[0].meta.end_ts, 'Do MMMM, h:mm a')
+ )
+ })
+ })
+
+ it('shows change action', function () {
+ cy.findAllByTestId('history-version-details')
+ .eq(1)
+ .within(() => {
+ cy.findByTestId('history-version-change-action').should(
+ 'have.text',
+ 'Created'
+ )
+ })
+ })
+
+ it('shows changed document name', function () {
+ cy.findAllByTestId('history-version-details')
+ .eq(2)
+ .within(() => {
+ cy.findByTestId('history-version-change-doc').should(
+ 'have.text',
+ updates.updates[2].pathnames[0]
+ )
+ })
+ })
+
+ it('shows users', function () {
+ cy.findAllByTestId('history-version-details')
+ .eq(1)
+ .within(() => {
+ cy.findByTestId('history-version-metadata-users')
+ .should('contain.text', 'You')
+ .and('contain.text', updates.updates[1].meta.users[1].first_name)
+ })
+ })
+ })
+
+ describe('labels only', function () {
+ beforeEach(function () {
+ mountWithEditorProviders(, scope, {
+ user: {
+ id: USER_ID,
+ email: USER_EMAIL,
+ isAdmin: true,
+ },
+ })
+ waitForData()
+ cy.findByLabelText(/labels/i).click({ force: true })
+ })
+
+ it('shows the dropdown menu item for adding new labels', function () {
+ cy.findAllByTestId('history-version-details')
+ .eq(1)
+ .within(() => {
+ cy.findByRole('button', { name: /more actions/i }).click()
+ cy.findByRole('menu').within(() => {
+ cy.findByRole('menuitem', {
+ name: /label this version/i,
+ }).should('exist')
+ })
+ })
+ })
+
+ it('resets from compare to view mode when switching tabs', function () {
+ cy.findAllByTestId('history-version-details')
+ .eq(1)
+ .within(() => {
+ cy.findByRole('button', {
+ name: /Compare/i,
+ }).click()
+ })
+ cy.findByLabelText(/all history/i).click({ force: true })
+ cy.findAllByTestId('history-version-details').should($versions => {
+ const [selected, ...rest] = Array.from($versions)
+ expect(selected).to.have.attr('data-selected', 'selected')
+ expect(
+ rest.every(version => version.dataset.selected === 'belowSelected')
+ ).to.be.true
+ })
+ })
+ it('opens the compare drop down and compares with selected version', function () {
+ cy.findByLabelText(/all history/i).click({ force: true })
+ cy.findAllByTestId('history-version-details')
+ .eq(3)
+ .within(() => {
+ cy.findByRole('button', {
+ name: /compare from this version/i,
+ }).click()
+ })
+
+ cy.findAllByTestId('history-version-details')
+ .eq(1)
+ .within(() => {
+ cy.get('[aria-label="Compare"]').click()
+ cy.findByRole('menu').within(() => {
+ cy.findByRole('menuitem', {
+ name: /compare up to this version/i,
+ }).click()
+ })
+ })
+
+ cy.findAllByTestId('history-version-details').should($versions => {
+ const [
+ aboveSelected,
+ upperSelected,
+ withinSelected,
+ lowerSelected,
+ belowSelected,
+ ] = Array.from($versions)
+ expect(aboveSelected).to.have.attr('data-selected', 'aboveSelected')
+ expect(upperSelected).to.have.attr('data-selected', 'upperSelected')
+ expect(withinSelected).to.have.attr('data-selected', 'withinSelected')
+ expect(lowerSelected).to.have.attr('data-selected', 'lowerSelected')
+ expect(belowSelected).to.have.attr('data-selected', 'belowSelected')
+ })
+ })
+ })
+
+ describe('compare mode', function () {
+ beforeEach(function () {
+ mountWithEditorProviders(, scope, {
+ user: {
+ id: USER_ID,
+ email: USER_EMAIL,
+ isAdmin: true,
+ },
+ })
+ waitForData()
+ })
+
+ it('compares versions', function () {
+ cy.findAllByTestId('history-version-details').should($versions => {
+ const [first, ...rest] = Array.from($versions)
+ expect(first).to.have.attr('data-selected', 'selected')
+ rest.forEach(version =>
+ // Based on the fact that we are selecting first version as we load the page
+ // Every other version will be belowSelected
+ expect(version).to.have.attr('data-selected', 'belowSelected')
+ )
+ })
+
+ cy.intercept('GET', '/project/*/filetree/diff*', {
+ body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
+ }).as('compareDiff')
+
+ cy.findAllByTestId('history-version-details')
+ .last()
+ .within(() => {
+ cy.findByTestId('compare-icon-version').click()
+ })
+ cy.wait('@compareDiff')
+ })
+ })
+
+ describe('dropdown', function () {
+ beforeEach(function () {
+ mountWithEditorProviders(, scope, {
+ user: {
+ id: USER_ID,
+ email: USER_EMAIL,
+ isAdmin: true,
+ },
+ })
+ waitForData()
+ })
+
+ it('adds badge/label', function () {
+ cy.findAllByTestId('history-version-details').eq(1).as('version')
+ cy.get('@version').within(() => {
+ cy.findByRole('button', { name: /more actions/i }).click()
+ cy.findByRole('menu').within(() => {
+ cy.findByRole('menuitem', {
+ name: /label this version/i,
+ }).click()
+ })
+ })
+ cy.intercept('POST', '/project/*/labels', req => {
+ req.reply(200, {
+ id: '64633ee158e9ef7da614c000',
+ comment: req.body.comment,
+ version: req.body.version,
+ user_id: USER_ID,
+ created_at: '2023-05-16T08:29:21.250Z',
+ user_display_name: 'john.doe',
+ })
+ }).as('addLabel')
+ const newLabel = 'my new label'
+ cy.findByRole('dialog').within(() => {
+ cy.findByRole('heading', { name: /add label/i })
+ cy.findByRole('button', { name: /cancel/i })
+ cy.findByRole('button', { name: /add label/i }).should('be.disabled')
+ cy.findByPlaceholderText(/new label name/i).as('input')
+ cy.get('@input').type(newLabel)
+ cy.findByRole('button', { name: /add label/i }).should('be.enabled')
+ cy.get('@input').type('{enter}')
+ })
+ cy.wait('@addLabel')
+ cy.get('@version').within(() => {
+ cy.findAllByTestId('history-version-badge').should($badges => {
+ const includes = Array.from($badges).some(badge =>
+ badge.textContent?.includes(newLabel)
+ )
+ expect(includes).to.be.true
+ })
+ })
+ })
+
+ it('downloads version', function () {
+ cy.intercept('GET', '/project/*/version/*/zip', { statusCode: 200 }).as(
+ 'download'
+ )
+ cy.findAllByTestId('history-version-details')
+ .eq(0)
+ .within(() => {
+ cy.findByRole('button', { name: /more actions/i }).click()
+ cy.findByRole('menu').within(() => {
+ cy.findByRole('menuitem', {
+ name: /download this version/i,
+ }).click()
+ })
+ })
+ cy.wait('@download')
+ })
+ })
+
+ describe('paywall', function () {
+ const now = Date.now()
+ const oneMinuteAgo = now - 60 * 1000
+ const justOverADayAgo = now - 25 * 60 * 60 * 1000
+ const twoDaysAgo = now - 48 * 60 * 60 * 1000
+
+ const updates = {
+ updates: [
+ {
+ fromV: 3,
+ toV: 4,
+ meta: {
+ users: [
+ {
+ first_name: 'john.doe',
+ last_name: '',
+ email: 'john.doe@test.com',
+ id: '1',
+ },
+ ],
+ start_ts: oneMinuteAgo,
+ end_ts: oneMinuteAgo,
+ },
+ labels: [],
+ pathnames: [],
+ project_ops: [{ add: { pathname: 'name.tex' }, atV: 3 }],
+ },
+ {
+ fromV: 1,
+ toV: 3,
+ meta: {
+ users: [
+ {
+ first_name: 'bobby.lapointe',
+ last_name: '',
+ email: 'bobby.lapointe@test.com',
+ id: '2',
+ },
+ ],
+ start_ts: justOverADayAgo,
+ end_ts: justOverADayAgo - 10 * 1000,
+ },
+ labels: [],
+ pathnames: ['main.tex'],
+ project_ops: [],
+ },
+ {
+ fromV: 0,
+ toV: 1,
+ meta: {
+ users: [
+ {
+ first_name: 'john.doe',
+ last_name: '',
+ email: 'john.doe@test.com',
+ id: '1',
+ },
+ ],
+ start_ts: twoDaysAgo,
+ end_ts: twoDaysAgo,
+ },
+ labels: [
+ {
+ id: 'label1',
+ comment: 'tag-1',
+ version: 0,
+ user_id: USER_ID,
+ created_at: justOverADayAgo,
+ },
+ ],
+ pathnames: [],
+ project_ops: [{ add: { pathname: 'main.tex' }, atV: 0 }],
+ },
+ ],
+ }
+
+ const labels = [
+ {
+ id: 'label1',
+ comment: 'tag-1',
+ version: 0,
+ user_id: USER_ID,
+ created_at: justOverADayAgo,
+ user_display_name: 'john.doe',
+ },
+ ]
+
+ const waitForData = () => {
+ cy.wait('@updates')
+ cy.wait('@labels')
+ cy.wait('@diff')
+ }
+
+ beforeEach(function () {
+ cy.intercept('GET', '/project/*/updates*', {
+ body: updates,
+ }).as('updates')
+ cy.intercept('GET', '/project/*/labels', {
+ body: labels,
+ }).as('labels')
+ cy.intercept('GET', '/project/*/filetree/diff*', {
+ body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
+ }).as('diff')
+ })
+
+ it('shows non-owner paywall', function () {
+ const scope = {
+ ui: {
+ view: 'history',
+ pdfLayout: 'sideBySide',
+ chatOpen: true,
+ },
+ }
+
+ mountWithEditorProviders(, scope, {
+ user: {
+ id: USER_ID,
+ email: USER_EMAIL,
+ isAdmin: false,
+ },
+ })
+
+ waitForData()
+
+ cy.get('.history-paywall-prompt').should('have.length', 1)
+ cy.findAllByTestId('history-version').should('have.length', 2)
+ cy.get('.history-paywall-prompt button').should('not.exist')
+ })
+
+ it('shows owner paywall', function () {
+ const scope = {
+ ui: {
+ view: 'history',
+ pdfLayout: 'sideBySide',
+ chatOpen: true,
+ },
+ }
+
+ mountWithEditorProviders(, scope, {
+ user: {
+ id: USER_ID,
+ email: USER_EMAIL,
+ isAdmin: false,
+ },
+ projectOwner: {
+ _id: USER_ID,
+ email: USER_EMAIL,
+ },
+ })
+
+ waitForData()
+
+ cy.get('.history-paywall-prompt').should('have.length', 1)
+ cy.findAllByTestId('history-version').should('have.length', 2)
+ cy.get('.history-paywall-prompt button').should('have.length', 1)
+ })
+
+ it('shows all labels in free tier', function () {
+ const scope = {
+ ui: {
+ view: 'history',
+ pdfLayout: 'sideBySide',
+ chatOpen: true,
+ },
+ }
+
+ mountWithEditorProviders(, scope, {
+ user: {
+ id: USER_ID,
+ email: USER_EMAIL,
+ isAdmin: false,
+ },
+ projectOwner: {
+ _id: USER_ID,
+ email: USER_EMAIL,
+ },
+ })
+
+ waitForData()
+
+ cy.findByLabelText(/labels/i).click({ force: true })
+
+ // One pseudo-label for the current state, one for our label
+ cy.get('.history-version-label').should('have.length', 2)
+ })
+ })
+})
diff --git a/services/web/test/frontend/features/history/components/change-list.spec.tsx b/services/web/test/frontend/features/history/components/change-list.spec.tsx
index 9087c1a616..051a5b63bf 100644
--- a/services/web/test/frontend/features/history/components/change-list.spec.tsx
+++ b/services/web/test/frontend/features/history/components/change-list.spec.tsx
@@ -31,7 +31,7 @@ const mountWithEditorProviders = (
)
}
-describe('change list', function () {
+describe('change list (Bootstrap 3)', function () {
const scope = {
ui: { view: 'history', pdfLayout: 'sideBySide', chatOpen: true },
}
@@ -363,7 +363,7 @@ describe('change list', function () {
cy.findAllByTestId('history-version-details')
.eq(1)
.within(() => {
- cy.findByRole('button', { name: /compare drop down/i }).click()
+ cy.findByRole('button', { name: /compare/i }).click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', {
name: /compare up to this version/i,